Skip to content

Commit 09f070c

Browse files
authored
fix(tests): stop the suite writing into the real ~/.agentwire; make session_metadata_path a real SSOT (#893, #899) (#904)
* fix(tests): stop the suite writing into the real ~/.agentwire; make the session-metadata path a real SSOT Closes #893 Closes #899 ~/.agentwire/sessions/resumed/metadata.json was a live record in the owner's config directory, written by the test suite. It had grown to 83 fabricated conversation ids — one appended per full-suite run, because conversation_ids is a chain by design (#871). Beyond tests mutating real user state being a defect on its own, it corrupted a measurement: sizing #871's orphaned-history doctor check against the real store surfaced 28 recorded ids with no transcript, ALL from that one record, with zero genuine orphans behind them. Two levers, because a path gets computed two ways: - $HOME is redirected per test. Path.home() resolves through expanduser, which reads the variable, so this catches everything computed at call time. - A walk over loaded modules rebinds the ~40 constants across ~25 modules that are computed at IMPORT time and already frozen before any fixture runs. Walking beats enumerating: a hand-written list would rot the first time someone adds a constant, which is how this class of bug recurs. The subtle part, found by a leak that survived the first attempt: much of this codebase imports lazily inside functions, so the first test to trigger such an import did it while $HOME already pointed at that test's tmp dir. The module then froze CONFIG_DIR to the FAKE home permanently — monkeypatch never patched it, so there was nothing to restore, and every later test read a constant belonging to a finished test. conftest now imports the whole package up front, before any redirect. Pinned by a test that goes red without it. The backstop is an audit hook, not a filesystem snapshot. A snapshot cannot tell this process from the rest of the machine, and ~/.agentwire is written continuously here by the live system; the snapshot version's first run flagged logs/damage-control/<today>.jsonl, which was an agent's shell command in another process. A guard that cries wolf gets switched off. An audit hook sees only this interpreter and fires on the syscall, so it names the offending test. A `real_agentwire_home` marker opts out the two places that must assert on real deployment paths (role prompts not being in a macOS-GC'd dir; control-plane protection of the owner's actual files). Read-only by intent — the backstop still fails the run if an opted-out test writes. test_locking cached LOCKS_DIR via `from ... import`, a second copy the redirect cannot rebind; it now reads the module attribute. Same shape as #899. The stale `resumed` record is deleted. Four callers hand-built the path: load_session_metadata, `agentwire kill`, and two I added in #894. Consolidating only the leaf would have left the ROOT built by hand wherever something enumerates rather than addresses, so core.sessions_dir() is added alongside and everything routes through both. The @machine strip now happens in exactly one place. Pins are behavioural, not textual: redirect the helper and every reader, writer and unlink must follow. Each was verified red by mutating behaviour — hand-building in load (3 red), in kill (1), a private root in known_sessions (1), and dropping the @-strip (1). That last initially did NOT bite: the test used a stand-in that did its own split, making it vacuous. Rewritten against the real implementation. A structural test also fails on any NEW hand-built copy. Full suite: 3424 passed. ruff clean. Did not touch build_agent_command or AGENTWIRE_LAUNCH_CMD (#901 is being fixed on epic-871-restart-verb). Built by dotdev.dev * fix(tests): keep the real $HOME for the damage-control corpus CI-only failure the local suite could not see. The #893 redirect points $HOME at a pytest tmp dir; on Linux that is under /tmp, and core.yaml allowlists `/tmp/*` with `allow: all`. An allowedPaths entry outranks zeroAccessPaths, so `cat ~/.ssh/id_rsa` canonicalized into the allowlisted temp tree and resolved to `allow` — the corpus went red for a reason with nothing to do with the matcher. Only the tilde vectors failed; the `$HOME`/`${HOME}` ones passed, which is what pointed at path-shape rather than expansion. Invisible on macOS because `/tmp` resolves to `/private/tmp`, which does not match `/tmp/*` — so `--basetemp=/tmp/...` does NOT reproduce it locally. The valid reproduction is setting HOME explicitly: HOME=/home/runner -> blocked=True HOME=/tmp/pytest-of-runner/pytest-0/home0 -> blocked=False Fixed with the `real_agentwire_home` marker: this corpus asserts on ~-form secret paths, so it needs $HOME to look like a real home. Reads only; the audit backstop still catches any write. Worth knowing independently of this PR: allowedPaths outranks zeroAccessPaths, so a secret under an allowlisted temp tree is readable. Real `~/.ssh` never lives there, so no production exposure — but the precedence is load-bearing. Full suite: 3424 passed. ruff clean. Built by dotdev.dev * fix(tests): make the write-guard see os.open, and give it tests that can fail Review findings on #904, plus one of my own found while chasing them. BLOCKING 1 — the audit hook could not see os.open. The `open` audit event is (path, mode, flags): mode is the string only for builtins.open/io.open, while os.open passes mode=None and carries the intent in flags. The check returned before recording, so detection was silently off for the seven production sites that create files under CONFIG_DIR via os.open — including core.write_role_prompt, which uses it precisely so the prompt is never briefly world-readable. Now reads mode OR flags. I hit this independently while chasing a record that reappeared with the guard silent, and the reviewer's report confirmed the same mechanism. BLOCKING 2 — and this is why the hole survived: the guard had no test that it could FAIL. The old "can it fail" test exercised a _snapshot() helper local to the test module, not the hook. There are now permanent tests that write for real under the real home and assert the hook recorded each one: builtins.open, os.open (the hole), a write_role_prompt-shaped 0600 create, Path.write_text, mkdir, unlink, and _atomic_write's os.replace — plus two negative cases (reads and writes elsewhere are ignored, since a guard that floods gets switched off). Those tests must write to the real home to be meaningful, which would trip the backstop they exercise, so home_guard.sanctioned_real_home_write() records them into a separate list the tests assert on. The sanction proves detection rather than disabling it, and nothing else in the suite uses it. MY OWN BUG, found doing that: pytest loads the root conftest as top-level `conftest`, while `import tests.conftest` yields a SECOND module object. Both ran the installer, so two hooks recorded every write and a flag set on one copy did not silence the other. That is the same second-copy-of-the-SSOT shape as #899, arriving by a different route — so detection now lives in one owning module, tests/home_guard.py, imported by absolute name from both sides, with an idempotent install(). Attribution also fixed: the fixture that labelled writes could be ordered after the write it meant to label. Reads pytest's own PYTEST_CURRENT_TEST instead. Import-time writes are sanctioned explicitly: importing the package calls agentwire_dir(), which mkdirs the real config dir. That is the package's own behaviour, and blaming whichever test ran first would be a lie. NON-BLOCKING, taken: session_metadata_path is now CONTAINED to the store. A session name is operator input and `agentwire kill` unlinks what this returns, so "../../../evil" addressed — and would have deleted — a file outside it. Pre-existing (main's inlined copy had identical properties), but consolidating is the moment the check can be written once instead of four times. Pinned, including that kill cannot unlink outside the store. Also routed two hand-builders that arrived with #902 (role_prompts.py, doctor_cli.py) through sessions_dir(); the structural test caught them on rebase, which is the test doing its job. Rebased onto ba57bf8 (#901). #902's autouse sweep stub is KEPT alongside this guard — they cover different halves (its stub prevents one destructive operation; this detects any write at all) and there are tests that both hold at once, including that no sweep stamp reaches the real store. KNOWN LIMITATION, stated rather than inherited silently: #902's sweep() can still be called with real paths by a test that passes them explicitly. Its stub covers tick(); closing the sweep() path is not this PR's change to make. Full suite: 3503 passed. ruff clean. Verification note: `sessions/resumed` and `role-prompt-sweep.json` still appear in the real store during runs on this machine — written by SIBLING worktrees running the suite without this guard (confirmed by ps; the audit hook is in-process and stayed silent on both of my back-to-back runs). That is evidence for #893 rather than against it: until every branch carries the guard, whichever checkout runs keeps polluting. Built by dotdev.dev * fix(tests): assert against the audit hook, not a snapshot of the real home The last snapshot-based assertion in the suite, and it flaked for exactly the reason this PR argues the guard must be in-process: ~/.agentwire is written continuously by the live fleet, so a before/after snapshot cannot tell THIS process from the rest of the machine. Sampling the real home three seconds apart on an idle box still showed 0/2/4/0/0 entries differing. The docstring two classes below already said so. Now the argument and the implementation agree: the assertion is `home_guard.WRITES[before:] == []`, and the `_snapshot()` helper is deleted rather than left as a trap for the next person. Verified it can still fail: injecting a real-home write into that test turns it red (and trips the session backstop), so this is a real assertion, not a weakened one. Full suite: 3503 passed. ruff clean. Built by dotdev.dev
1 parent 65fe5f1 commit 09f070c

15 files changed

Lines changed: 958 additions & 40 deletions

‎agentwire/core.py‎

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,51 @@ def _output_result(success: bool, json_mode: bool, message: str = "", exit_code:
10001000
return 0 if success else 1
10011001

10021002

1003+
def sessions_dir() -> Path:
1004+
"""The session-record store. The ROOT half of the path SSOT (#899).
1005+
1006+
Consolidating only the leaf left this built by hand wherever something
1007+
*enumerates* records rather than addressing one — which is how a "single
1008+
source of truth" ends up with two spellings of where it lives.
1009+
"""
1010+
return CONFIG_DIR / "sessions"
1011+
1012+
1013+
def session_metadata_path(session_name: str) -> Path:
1014+
"""Where a session's record lives. One implementation, every direction.
1015+
1016+
Read, written, enumerated and unlinked through here — reads by
1017+
:func:`load_session_metadata`, writes by :func:`store_session_metadata`,
1018+
and the unlink by ``agentwire kill``. The docstring used to claim "both
1019+
directions" while the reader and the unlink each rebuilt the path inline
1020+
(#899), which is the same shape as ``tmux_safe_name`` (#865 → #868 → #870
1021+
→ #878) and ``encode_project_path`` (#892): the helper existed, and callers
1022+
simply did not route through it. In every prior round the divergence stayed
1023+
invisible until production behaved wrongly.
1024+
1025+
The ``@machine`` suffix is stripped HERE and nowhere else — the store is
1026+
keyed by bare session name, so ``web@remote`` and ``web`` are one record.
1027+
1028+
The result is CONTAINED to the store. A session name is operator-supplied
1029+
and reaches this from the CLI, and the path it returns is unlinked by
1030+
``agentwire kill`` — so ``../../../evil`` would have addressed, and
1031+
deleted, a file outside the store entirely. That was equally true of the
1032+
inlined copy this replaced, but consolidating is the moment the check
1033+
becomes possible to write once instead of four times.
1034+
"""
1035+
clean = session_name.split("@")[0]
1036+
root = sessions_dir()
1037+
candidate = root / clean / "metadata.json"
1038+
try:
1039+
resolved = candidate.resolve(strict=False)
1040+
resolved.relative_to(root.resolve(strict=False))
1041+
except (ValueError, OSError):
1042+
raise ValueError(
1043+
f"session name escapes the session store: {session_name!r}"
1044+
) from None
1045+
return candidate
1046+
1047+
10031048
def load_session_metadata(session_name: str) -> dict:
10041049
"""Load session metadata from storage.
10051050
@@ -1009,10 +1054,7 @@ def load_session_metadata(session_name: str) -> dict:
10091054
Returns:
10101055
Dictionary of metadata (empty dict if not found)
10111056
"""
1012-
# Parse session name to extract just the name part (remove @machine)
1013-
clean_name = session_name.split("@")[0]
1014-
1015-
metadata_file = CONFIG_DIR / "sessions" / clean_name / "metadata.json"
1057+
metadata_file = session_metadata_path(session_name)
10161058

10171059
if not metadata_file.exists():
10181060
return {}
@@ -1024,11 +1066,6 @@ def load_session_metadata(session_name: str) -> dict:
10241066
return {}
10251067

10261068

1027-
def session_metadata_path(session_name: str) -> Path:
1028-
"""Where a session's record lives. One implementation, both directions."""
1029-
return CONFIG_DIR / "sessions" / session_name.split("@")[0] / "metadata.json"
1030-
1031-
10321069
def recorded_sessions() -> list[str]:
10331070
"""Every session name with a launch record on disk, sorted.
10341071
@@ -1045,11 +1082,11 @@ def recorded_sessions() -> list[str]:
10451082
records on this machine — a sweep built on it would silently skip 58% of
10461083
the fleet while reporting itself clean.
10471084
"""
1048-
sessions_dir = CONFIG_DIR / "sessions"
1085+
root = sessions_dir()
10491086
try:
10501087
return sorted(
1051-
str(f.parent.relative_to(sessions_dir))
1052-
for f in sessions_dir.glob("**/metadata.json")
1088+
str(f.parent.relative_to(root))
1089+
for f in root.glob("**/metadata.json")
10531090
)
10541091
except OSError:
10551092
return []

‎agentwire/doctor_cli.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,7 @@ def _render_role_prompt_store_section(
843843
from . import core, role_prompts
844844

845845
store = core.role_prompts_dir()
846-
sessions_dir = core.CONFIG_DIR / "sessions"
846+
sessions_dir = core.sessions_dir()
847847
s = role_prompts.status(store, sessions_dir)
848848

849849
if not s["exists"]:

‎agentwire/history_migrate.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
import uuid
4242
from pathlib import Path
4343

44-
from .core import CONFIG_DIR, load_session_metadata
44+
from .core import load_session_metadata, recorded_sessions, session_metadata_path
4545
from .history import (
4646
PROJECTS_DIR,
4747
history_key_candidates,
@@ -374,7 +374,7 @@ def resolve_session(session_name: str) -> dict:
374374
# load_session_metadata returns {} both for "no such session" and for "a
375375
# session with an empty record", so distinguish them here rather than
376376
# telling someone who mistyped a name that their session predates #881.
377-
if not (CONFIG_DIR / "sessions" / session_name.split("@")[0] / "metadata.json").is_file():
377+
if not session_metadata_path(session_name).is_file():
378378
return {**base, "status": UNDETERMINED, "detail": "no such session recorded"}
379379

380380
old = metadata.get("cwd_at_launch")
@@ -437,11 +437,18 @@ def _find_worktree_cached(finder, repo: str, branch: str | None, name: str) -> d
437437

438438

439439
def known_sessions() -> list[str]:
440-
"""Session names that have a metadata record, in stable order."""
441-
root = CONFIG_DIR / "sessions"
442-
if not root.is_dir():
443-
return []
444-
return sorted(d.name for d in root.iterdir() if (d / "metadata.json").is_file())
440+
"""Session names that have a metadata record, in stable order.
441+
442+
Delegates to :func:`core.recorded_sessions` rather than scanning here.
443+
This used to do a FLAT scan of the store, which is wrong for the reason
444+
#898 measured: session names contain slashes by design (``project/branch``
445+
is what every ``agentwire worktree`` and every scheduler dispatch is
446+
called), so those records nest one level deeper. A flat scan found 469 of
447+
1106 records on that machine — this sweep would have silently skipped 58%
448+
of the fleet while reporting itself clean, which is the exact failure mode
449+
:func:`scan` exists to catch in someone else's data.
450+
"""
451+
return recorded_sessions()
445452

446453

447454
def scan() -> list[dict]:

‎agentwire/pane_cli.py‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121

2222
from . import pane_manager
2323
from .core import (
24-
CONFIG_DIR,
2524
_add_posture_flag,
2625
_check_tmux_installed,
2726
_display_parent,
@@ -39,6 +38,7 @@
3938
load_config,
4039
load_session_metadata,
4140
parse_env_args,
41+
session_metadata_path,
4242
tmux_session_exists,
4343
)
4444
from .project_config import load_project_config
@@ -579,6 +579,28 @@ def _wants_graceful_exit(posture: str | None, pane_command: str | None) -> bool:
579579
return True
580580

581581

582+
def _drop_session_metadata(session: str) -> None:
583+
"""Remove a killed session's record, addressed through the path SSOT (#899).
584+
585+
Dropping it is deliberate: the record carries ``created_by``, so leaving it
586+
behind would let an unrelated future session that reuses the name inherit a
587+
stale parent. Consistent with the store's lifetime — when the session is
588+
gone, so is the tmux environment holding its launch line, and nothing can
589+
re-read it.
590+
591+
Routed through :func:`session_metadata_path` rather than reassembling the
592+
store layout inline, so an unlink can never address a different file than
593+
the write did.
594+
"""
595+
path = session_metadata_path(session)
596+
if not path.exists():
597+
return
598+
try:
599+
path.unlink()
600+
except OSError:
601+
pass
602+
603+
582604
def cmd_kill(args) -> int:
583605
"""Kill a tmux session or pane (graceful /exit first, then kill).
584606
@@ -757,14 +779,7 @@ def kill_local_session(session: str, force: bool = False, timeout: int = 10,
757779
if verbose:
758780
print(f"Killed session '{session}'")
759781

760-
# Drop session metadata (creator record) so a future unrelated session
761-
# reusing this name doesn't inherit a stale parent.
762-
metadata_file = CONFIG_DIR / "sessions" / session.split("@")[0] / "metadata.json"
763-
if metadata_file.exists():
764-
try:
765-
metadata_file.unlink()
766-
except OSError:
767-
pass
782+
_drop_session_metadata(session)
768783

769784
# GC this sender's still-pending outbound across every recipient inbox (#621)
770785
# so report-backs it left undelivered don't accumulate. Load-bearing kinds

‎agentwire/role_prompts.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ def tick() -> dict:
274274
from . import core
275275

276276
store = core.role_prompts_dir()
277-
sessions_dir = core.CONFIG_DIR / "sessions"
277+
sessions_dir = core.sessions_dir()
278278
stamp = core.CONFIG_DIR / "role-prompt-sweep.json"
279279
now = time.time()
280280

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ markers = [
235235
# Deselected in the hermetic CI gate via `-m 'not requires_tmux'` (#323);
236236
# still runs locally where tmux is on PATH.
237237
"requires_tmux: spawns a real tmux process; deselected in hermetic CI (#323)",
238+
# Opts a test out of the ~/.agentwire redirect so it can assert on the REAL
239+
# deployment paths (#893). READ-ONLY by intent — the session-scoped backstop
240+
# still fails the run if an opted-out test writes anything.
241+
"real_agentwire_home: sees the real ~/.agentwire paths instead of the tmp redirect (#893)",
238242
]
239243
asyncio_mode = "auto"
240244

‎tests/conftest.py‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,157 @@
11
"""Shared test fixtures for the AgentWire test suite."""
22

33
import os
4+
import sys
45
from pathlib import Path
56

67
import pytest
78
import yaml
89

10+
from tests import home_guard
11+
912
FIXTURES_DIR = Path(__file__).parent / "fixtures"
1013

14+
#: The owner's real config directory. Nothing in the suite may write here.
15+
#: Re-exported from the single owner so both halves agree on what "real" is.
16+
REAL_AGENTWIRE_HOME = home_guard.REAL_AGENTWIRE_HOME
17+
18+
home_guard.install()
19+
20+
21+
def _agentwire_modules():
22+
"""Loaded agentwire modules, snapshotted (import can mutate sys.modules)."""
23+
return [m for name, m in list(sys.modules.items())
24+
if name == "agentwire" or name.startswith("agentwire.")
25+
if m is not None]
26+
27+
28+
def _import_every_agentwire_module():
29+
"""Import the whole package once, BEFORE any test redirects ``$HOME``.
30+
31+
Load-bearing, and subtle. The redirect below works by rebinding
32+
module-level constants that were computed at import time — but it can only
33+
rebind modules that are *already imported*. Much of this codebase imports
34+
lazily inside functions (``from agentwire.__main__ import
35+
build_agent_command`` inside a test helper), so without this the first
36+
test to trigger such an import does it while ``$HOME`` already points at
37+
that test's tmp directory. The module then computes
38+
``CONFIG_DIR = Path.home() / ".agentwire"`` against the *fake* home and
39+
freezes it there — permanently, for the rest of the session, because
40+
monkeypatch never patched it and so has nothing to restore.
41+
42+
The symptom is a constant stuck at some early test's tmp path, which is
43+
exactly the kind of cross-test bleed this fixture exists to prevent. Doing
44+
the imports up front means every constant is computed against the real
45+
home, so the per-test walk both rebinds and restores it.
46+
47+
Best-effort: a submodule that cannot import (optional dependency, platform
48+
guard) is skipped rather than failing collection.
49+
"""
50+
import importlib
51+
import pkgutil
52+
53+
import agentwire
54+
55+
# Importing the package touches the real config dir: `agentwire_dir()`
56+
# both resolves AND mkdirs, and some modules call it at import. That is the
57+
# package's own import-time behaviour, not a test polluting the store, and
58+
# attributing it to whichever test happened to be first would be a lie. It
59+
# is sanctioned rather than silenced, so it still shows up in
60+
# SANCTIONED_WRITES if anyone wants to look.
61+
with home_guard.sanctioned_real_home_write():
62+
for info in pkgutil.walk_packages(agentwire.__path__, prefix="agentwire."):
63+
try:
64+
importlib.import_module(info.name)
65+
except Exception:
66+
continue
67+
68+
69+
_import_every_agentwire_module()
70+
71+
72+
@pytest.fixture(autouse=True)
73+
def _isolate_agentwire_home(request, tmp_path_factory, monkeypatch):
74+
"""No test may read or write the real ``~/.agentwire`` — ever (#893).
75+
76+
Found the hard way: ``~/.agentwire/sessions/resumed/metadata.json`` was a
77+
live record in the owner's config directory, written by this suite and
78+
grown to 80 fabricated conversation ids — one appended per full-suite run,
79+
because ``conversation_ids`` is a chain by design (#871). Beyond tests
80+
mutating real user state being a defect on its own, it corrupted a
81+
measurement: sizing #871's orphaned-history doctor check against the real
82+
store surfaced 28 recorded ids with no transcript, *all* from that one
83+
record, with zero genuine orphans behind them.
84+
85+
Two levers, because there are two ways a path gets computed:
86+
87+
1. **``$HOME``** — ``Path.home()`` resolves through ``expanduser``, which
88+
reads the variable, so redirecting it catches everything computed at
89+
*call* time, including modules imported later by a lazy import.
90+
2. **A walk over loaded modules** — roughly forty constants across ~25
91+
modules are computed at *import* time
92+
(``COHORT_ROOT = Path.home() / ".agentwire" / "cohorts"`` and friends)
93+
and are already frozen before any fixture runs. Rebinding them by
94+
walking beats enumerating them: a hand-written list would rot the first
95+
time someone adds a constant, which is exactly how this class of bug
96+
recurs. ``test_home_isolation.py`` asserts the walk missed nothing.
97+
98+
Per-test rather than per-session, so no test can observe another's writes.
99+
Tests that need their own location re-patch the same attributes via
100+
``monkeypatch``, which overrides this.
101+
"""
102+
# Escape hatch for the rare test that must see the REAL deployment paths
103+
# to assert on them — e.g. "role prompts are not in a directory macOS
104+
# garbage-collects", which this fixture would otherwise make vacuous by
105+
# relocating them into exactly such a directory. Read-only by intent, and
106+
# not a hole: the session-scoped backstop below still fails the run if an
107+
# opted-out test writes anything.
108+
if request.node.get_closest_marker("real_agentwire_home"):
109+
return REAL_AGENTWIRE_HOME
110+
111+
fake_home = tmp_path_factory.mktemp("home")
112+
fake_config = fake_home / ".agentwire"
113+
fake_config.mkdir(parents=True, exist_ok=True)
114+
115+
monkeypatch.setenv("HOME", str(fake_home))
116+
monkeypatch.setenv("AGENTWIRE_HOME", str(fake_config))
117+
118+
for module in _agentwire_modules():
119+
for attr, value in list(vars(module).items()):
120+
if not isinstance(value, Path):
121+
continue
122+
if value == REAL_AGENTWIRE_HOME:
123+
monkeypatch.setattr(module, attr, fake_config, raising=False)
124+
elif REAL_AGENTWIRE_HOME in value.parents:
125+
relocated = fake_config / value.relative_to(REAL_AGENTWIRE_HOME)
126+
monkeypatch.setattr(module, attr, relocated, raising=False)
127+
128+
# ``agentwire_dir()`` both resolves AND mkdirs, and callers bound it with
129+
# ``from .utils.paths import agentwire_dir`` — a per-module copy that
130+
# patching the definition site would not reach.
131+
def _fake_agentwire_dir() -> Path:
132+
fake_config.mkdir(parents=True, exist_ok=True)
133+
return fake_config
134+
135+
for module in _agentwire_modules():
136+
if callable(vars(module).get("agentwire_dir")):
137+
monkeypatch.setattr(module, "agentwire_dir", _fake_agentwire_dir, raising=False)
138+
139+
return fake_config
140+
141+
142+
@pytest.fixture(scope="session", autouse=True)
143+
def _real_agentwire_home_untouched():
144+
"""Backstop: fail the run loudly if anything escaped the redirect (#893).
145+
146+
The redirect above is prevention; this is detection. Implementation lives
147+
in :mod:`tests.home_guard` so there is exactly one recorder — see the note
148+
there about pytest loading this file under two module names.
149+
"""
150+
yield
151+
failure = home_guard.report()
152+
if failure:
153+
pytest.fail(failure, pytrace=False)
154+
11155

12156
@pytest.fixture(autouse=True)
13157
def _no_real_outbound_email(request, monkeypatch):

0 commit comments

Comments
 (0)