"""Issue #55 - helpers for the session guard over the user's real agent homes. An agent pane inherits CLAUDE_PROJECT_DIR= from lib.sh (every MAM pane does), and DiscoveryContext prefers that variable over the home_dir a test passes, so a suite run by an agent wrote fake conversation folders into the user's real claude directory. The per-test fixture (conftest._isolate_agent_homes) is the fix; this guard is the alarm: it snapshots the direct entries of every real conversation directory before the suite and reports the new ones after it. Read-only - it never hashes, touches or removes anything there. Pure functions, unit-tested in test_issue55_agent_homes.py; the name does not start with `test_`, so pytest never collects it. """ from __future__ import annotations import os import tempfile # The pane-inherited variables conftest._isolate_agent_homes drops for every # test, so HOME (which the tests set) governs, as it does in a plain shell. DROPPED_VARS = ("HOME_DIR", "CLAUDE_PROJECT_DIR", "CODEX_HOME", "LOCAL_BIN") # Read by the code under test but left alone, on purpose: HOME is where the # real herdr tests and the shim bootstrap find the server socket, and it is # what the suite's own tests set for an isolated home; XDG_CONFIG_HOME and # HERDR_CONFIG_PATH are herdr's own config locations (mam_sandbox unsets # them, Issue #45). LEFT_ALONE = ("HOME", "XDG_CONFIG_HOME", "HERDR_CONFIG_PATH") def real_home(ambient): return ambient.get("HOME_DIR") or ambient.get("HOME") or os.path.expanduser("~") def real_claude_dir(ambient): return ambient.get("CLAUDE_PROJECT_DIR") or os.path.join(real_home(ambient), ".claude", "projects") def watch_dirs(ambient): """[(agent, directory)]: where each adapter reads and writes conversations, as the ambient environment (recorded before any fixture ran) names them.""" home = real_home(ambient) codex = ambient.get("CODEX_HOME") or os.path.join(home, ".codex") dirs = [("claude", real_claude_dir(ambient))] dirs += [("agy", os.path.join(home, ".gemini", "antigravity-cli", d)) for d in ("conversations", "cache", "brain", "annotations", "presence")] dirs += [("codex", os.path.join(codex, "sessions")), ("grok", os.path.join(home, ".grok", "sessions")), ("opencode", os.path.join(home, ".local", "share", "opencode")), ("hermes", os.path.join(home, ".hermes"))] return dirs def _listdir(path): try: return set(os.listdir(path)) except OSError: return set() def snapshot(dirs): """Direct entry names per watched directory; a missing one counts as empty.""" return {d: _listdir(d) for _agent, d in dirs} def decode_ws_key(name, root="/"): """The existing directory whose workspace key (lib_py.verify_session: realpath with `/` and `_` turned into `-`) is `name`, or None. A key is ambiguous on its own (a `-` may have been a `/`, a `_` or a `-`), so it is read against the file system: from `root`, each component is an existing entry whose own key is the next piece of the name.""" if not name.startswith("-"): return None stack = [(root, name[1:])] while stack: cur, rest = stack.pop() if rest == "": return cur try: entries = os.listdir(cur) except OSError: continue for entry in entries: key = entry.replace("_", "-") if rest == key: full = os.path.join(cur, entry) if os.path.isdir(full): return full elif rest.startswith(key + "-"): stack.append((os.path.join(cur, entry), rest[len(key) + 1:])) return None def _under(path, roots): real = os.path.realpath(path) return any(real == r or real.startswith(r + os.sep) for r in roots) def leaks(dirs, before, claude_dir, tmp_roots=()): """New entries since `before`, as [{"path", "agent"}] sorted by path. One exception, for the claude directory only: an entry whose name decodes to a directory that exists on this host and is not under a temp root (tempfile.gettempdir() and the given `tmp_roots`, e.g. pytest's basetemp) is a conversation an operating agent started meanwhile, not a test's. Every other directory has no exception.""" roots = [os.path.realpath(tempfile.gettempdir())] + [os.path.realpath(r) for r in tmp_roots] claude_real = os.path.realpath(claude_dir) now = snapshot(dirs) found = [] for agent, d in dirs: for name in sorted(now[d] - before.get(d, set())): if os.path.realpath(d) == claude_real: ws = decode_ws_key(name) if ws is not None and not _under(ws, roots): continue found.append({"path": os.path.join(d, name), "agent": agent}) return sorted(found, key=lambda f: f["path"]) def describe(found): """Failure message for the session guard.""" lines = ["Issue #55: {} entr{} made during this session in a real agent home " "(left in place; check and remove by hand):".format(len(found), "y" if len(found) == 1 else "ies")] lines += [" {path} [{agent}]".format(**f) for f in found] return "\n".join(lines)