test(issue-55): tests no longer write into the user's real agent homes
An agent pane inherits HOME_DIR, CLAUDE_PROJECT_DIR and LOCAL_BIN from
lib.sh's exports (and the adapters read CODEX_HOME). DiscoveryContext
prefers CLAUDE_PROJECT_DIR over the home_dir a test passes and every
adapter path reads HOME_DIR before HOME, so a suite run by an agent
wrote fake conversation folders into the user's real ~/.claude/projects
(test_adapter_discover, 40 folders on 2026-10-10).
- conftest._isolate_agent_homes (autouse): drops the four pane variables
for every test, so HOME - which the suite's tests set for an isolated
home - governs, as in a plain shell. Dropped rather than pointed into
tmp_path: a fixture-set HOME_DIR would shadow the 11 adapter-contract
tests that set HOME alone, the same way the pane's value did.
- conftest.agent_home_leak_guard (session autouse) + tests/_agent_home_guard.py:
snapshots the entry names of the real claude/agy/codex/grok/opencode/
hermes conversation directories (as the ambient environment names them,
recorded in pytest_configure) and fails on new ones; read-only. A claude
folder whose name decodes (against the file system) to a real directory
outside the temp roots is an operating agent's and is not counted.
- tests/test_issue55_agent_homes.py: the dropped-variable list is pinned
against the env reads of lib_py/agents, lib_py/paths.py and lib.sh;
guard unit tests; the issue's reproduction as a subprocess regression
(on base 42e5173 it leaves <dir>/-…-test-adapter-discover0-ws/u-c1.jsonl).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
42e51735ba
commit
40293841e7
3 files changed
+381
No files matched your search
@@ -0,0 +1,125 @@
|
||||
"""Issue #55 - helpers for the session guard over the user's real agent homes.
|
||||
|
||||
An agent pane inherits CLAUDE_PROJECT_DIR=<real ~/.claude/projects> 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)
|
||||
Reference in new issue
Block a user