Files
GodopuandClaude Opus 5.5 0e05b1b677 fix(opencode): tests drop XDG_DATA_HOME, status reads the adapter's db, the version probe runs in a home of its own (Issue #60, review 2)
- Review 8a485fa0 B1 / c9d06e85 / c4e96018 (blocking): the adapter now
  follows the caller's XDG_DATA_HOME for its own home, but the tests did not
  drop it: a sandbox that sets HOME resolved the runner's real data dir.
  XDG_DATA_HOME joins _agent_home_guard.DROPPED_VARS (dropped per test), and
  the session guard watches <XDG_DATA_HOME>/opencode when it is set.
  Checked with XDG_DATA_HOME set to a separate dir: the opencode modules
  pass and nothing is written there.
- status.sh asks the adapter for the db (`debug paths`, XDG-aware) instead
  of a fixed ~/.local/share/opencode (8a485fa0 N1).
- The opt-in test's `opencode --version` probe ran with the real HOME at
  import and appended to the real opencode log; it runs in a temporary home
  now (8a485fa0 N2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-10-18 16:36:12 +09:00

127 lines
5.3 KiB
Python

"""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", "CLAUDE_CONFIG_DIR", "CODEX_HOME", "XDG_DATA_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(ambient.get("XDG_DATA_HOME") or 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)