"""Profile-isolation write tripwire: no cross-profile writes to the default home. Invariant under test -------------------- When a non-default profile is active (``HERMES_HOME`` points at ``/profiles/testprof``), NO subsystem write may land anywhere under the default profile's tree — ``/state.db``, ``/config.yaml``, ``/memories/``, ``/cron/``, or any other path directly under ```` outside the ``profiles/`` subtree. This is a recurring bug *class*, not a single bug. Recent regressions: * #88532 — gateway sessions physically stored in the root ``state.db`` even though the profile scope was active (rows carried the right profile_name, so the only symptom was the desktop listing them under the default bot). * #92662 / #89190 — settings and API keys saved while a profile was active were written to the base profile's ``config.yaml`` / ``.env``. * #89625 — cron listing/writing the default profile's jobs.json because the store paths were frozen at import time. * #92156 — terminal env cache leaked across profiles (same import-time- snapshot shape of bug). Each test here exercises a REAL persistence surface through its real code path (no mocks of the subject) with the ``testprof`` profile active, then walks the entire default-profile tree and fails if anything was created, modified, or deleted there. The walk is deliberately whole-tree rather than per-known-file so the *next* variant of this bug class (a new subsystem, a new sidecar file) trips it too. Reusability ----------- ``ProfileTripwire`` and the ``profile_tripwire`` fixture are module-level and documented so future surfaces (terminal env cache, skills, plugins, logs …) can add one small exercise function + a ``pytest.param`` to ``SURFACES``. """ import os import sqlite3 from pathlib import Path import pytest import hermes_state from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, ) class ProfileTripwire: """Snapshot of the default profile's tree + the active testprof home. Construction snapshots every path under ``root`` EXCEPT the ``root/profiles`` subtree (that subtree is exactly where writes are supposed to go). ``assert_default_untouched()`` re-walks and fails on any created / modified / deleted entry. Attributes: root: the default profile's home (``/hermes``). profile: the active profile home (``/profiles/testprof``). """ def __init__(self, root: Path, profile: Path): self.root = root self.profile = profile self._baseline = self._snapshot() def _snapshot(self) -> dict: """Map of relpath -> (kind, mtime_ns, size) for the default tree.""" snap = {} profiles_subtree = self.root / "profiles" for dirpath, dirnames, filenames in os.walk(self.root): d = Path(dirpath) if d == self.root and "profiles" in dirnames: dirnames.remove("profiles") # never descend into profiles/ for name in dirnames: p = d / name if p == profiles_subtree: continue snap[str(p.relative_to(self.root))] = ("dir", None, None) for name in filenames: p = d / name st = p.stat() snap[str(p.relative_to(self.root))] = ( "file", st.st_mtime_ns, st.st_size, ) return snap def assert_default_untouched(self) -> None: """Fail if the default profile's tree changed in any way.""" now = self._snapshot() created = sorted(set(now) - set(self._baseline)) deleted = sorted(set(self._baseline) - set(now)) modified = sorted( rel for rel in set(now) & set(self._baseline) if now[rel] != self._baseline[rel] ) problems = [] if created: problems.append(f"created in default profile: {created}") if modified: problems.append(f"modified in default profile: {modified}") if deleted: problems.append(f"deleted from default profile: {deleted}") assert not problems, ( "Cross-profile write leak (bug class #88532/#92662/#89190/" "#89625/#92156): the testprof profile was active but the default " f"profile tree at {self.root} changed: " + "; ".join(problems) ) @pytest.fixture def profile_tripwire(tmp_path, monkeypatch): """Activate ``/profiles/testprof`` and arm the default-tree tripwire. Sets up: 1. A temp hermes root acting as the *default* profile, seeded with the files a real install has (config.yaml, memories/, cron/jobs.json) so both "new file created" and "existing file modified" leaks are detectable. 2. ``HERMES_HOME`` (env var AND the context-local override) pointed at the testprof home — the exact activation shape ``--profile`` uses. 3. ``hermes_state.DEFAULT_DB_PATH`` restored to its import-time snapshot. The suite conftest deliberately re-points that constant at its own fake home, which trips the escape hatch in ``_default_db_path()`` (a re-pointed constant wins over everything). Closing the hatch makes resolution flow through ``get_hermes_home()`` — the production path, and the one #88532 regressed. Yields a :class:`ProfileTripwire`; teardown re-asserts the invariant so a surface test that forgets the explicit check still trips the wire. """ root = tmp_path / "hermes" profile = root / "profiles" / "testprof" profile.mkdir(parents=True) # Seed the default profile the way a real install looks. (root / "config.yaml").write_text("model:\n default: default-model\n") (root / "memories").mkdir() (root / "memories" / "MEMORY.md").write_text("default profile memory\n") (root / "cron").mkdir() (root / "cron" / "jobs.json").write_text("[]\n") (root / ".env").write_text("") monkeypatch.setenv("HERMES_HOME", str(profile)) token = set_hermes_home_override(str(profile)) # Close the conftest's DEFAULT_DB_PATH escape hatch (see docstring). monkeypatch.setattr( hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH ) tripwire = ProfileTripwire(root, profile) try: yield tripwire # Safety net: re-check even if the test body forgot to. tripwire.assert_default_untouched() finally: reset_hermes_home_override(token) # --------------------------------------------------------------------------- # Surface exercisers — each drives ONE real persistence surface end to end. # Add new surfaces here as the bug class produces new variants. # --------------------------------------------------------------------------- def _exercise_session_db(tripwire: ProfileTripwire) -> None: """SessionDB() argless construction + session + message (#88532).""" db = hermes_state.SessionDB() try: db.create_session("20260823_000000_tripwire", "cli") db.append_message("20260823_000000_tripwire", "user", "hello") finally: db.close() profile_state_db = tripwire.profile / "state.db" assert profile_state_db.exists(), ( f"SessionDB() did not write to the active profile home " f"({profile_state_db} missing)" ) def _exercise_config_save(tripwire: ProfileTripwire) -> None: """save_config()/load_config() while a profile is active (#92662, #89190).""" from hermes_cli.config import load_config, save_config cfg = load_config() cfg["model"] = "testprof-model" # bare-string alias form is canonical save_config(cfg) assert (tripwire.profile / "config.yaml").exists(), ( "save_config() did not write the active profile's config.yaml" ) def _exercise_memory_store(tripwire: ProfileTripwire) -> None: """MemoryStore.add() persistence path (same class as #92662).""" from tools.memory_tool import MemoryStore store = MemoryStore() store.load_from_disk() result = store.add("memory", "testprof remembers something") assert result.get("success"), f"memory add failed: {result}" mem_file = tripwire.profile / "memories" / "MEMORY.md" assert mem_file.exists(), ( "MemoryStore did not persist to the active profile's memories/" ) assert "testprof remembers something" in mem_file.read_text() def _exercise_cron_store(tripwire: ProfileTripwire) -> None: """cron save_jobs()/load_jobs() store resolution (#89625).""" from cron.jobs import load_jobs, save_jobs job = { "id": "tripwire-job", "name": "tripwire", "prompt": "noop", "schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"}, "enabled": True, } save_jobs([job]) jobs_file = tripwire.profile / "cron" / "jobs.json" assert jobs_file.exists(), ( "save_jobs() did not write the active profile's cron/jobs.json" ) loaded = load_jobs() assert any(j.get("id") == "tripwire-job" for j in loaded), ( "load_jobs() did not read back from the active profile's store " "(#89625 shape: reading the default profile's jobs instead)" ) # And the default profile's jobs.json content is untouched (the tree # walk would also catch this; the explicit read documents the contract). assert (tripwire.root / "cron" / "jobs.json").read_text() == "[]\n" # NOTE: the terminal env cache surface (#92156) is not exercised here: the # terminal backend cache requires spawning a real terminal session, which is # not hermetic in this suite. Its bug shape (import-time path snapshot) is # the same one the four surfaces above pin. SURFACES = [ pytest.param(_exercise_session_db, id="session_db"), pytest.param(_exercise_config_save, id="config_save"), pytest.param(_exercise_memory_store, id="memory_store"), pytest.param(_exercise_cron_store, id="cron_store"), ] @pytest.mark.parametrize("exercise", SURFACES) def test_no_default_profile_writes(profile_tripwire, exercise): """Exercising a persistence surface under testprof must not touch . Regression tripwire for the cross-profile write bug class: #88532 (sessions in root state.db), #92662/#89190 (settings/API keys in the base profile), #89625 (cron using the default profile's store), #92156 (terminal env cache leaking cross-profile). """ exercise(profile_tripwire) profile_tripwire.assert_default_untouched()