hermes-agent/tests/tools/test_file_tools_cwd_resolution.py
ethernet c80b244b52 refactor(terminal,file-tools): delete legacy env-side cwd tracking (step 4)
The per-session record store is now the ONLY cwd mechanism. Deleted:

- env.cwd_owner stamping + prev_owner threading (terminal_tool): the
  shared env no longer carries ownership metadata at all
- _resolve_command_cwd's env/prev_owner params: resolution is
  workdir > session record > config/override default
- file_tools._live_cwd_if_owned + _get_live_tracking_cwd: path
  resolution never consults the shared env's live cwd
- file_tools._last_known_cwd + _remember_last_known_cwd +
  _last_known_cwd_for: the #26211 preserved-anchor registry is
  subsumed by the session record, which never lived on the env and
  therefore cannot be lost to env cleanup. The _get_file_ops
  stale-cache rescue now writes the record instead.
- env recreation (both _get_file_ops and terminal_tool) seeds the
  fresh env from override > session record > config

Why no transition fallback: the legacy state was process-local and
in-memory exactly like the record store — after a restart both start
empty, and within a running process every legacy write site has been
dual-writing the record since step 1. There is no populated-legacy/
empty-record state to fall back for.

Tests updated to drive the record store instead of the deleted
mechanism; the cross-session isolation suite now asserts the same
behavior contracts (no leak, cd isolation, #26211 persistence)
against the new architecture, plus a new "session C inherits nothing"
case that the old ownership guard could not express.
2026-07-16 00:18:38 -07:00

461 lines
19 KiB
Python

"""Regression tests for file-tool path resolution base correctness.
The bug (observed in a worktree dev session, May 2026): when the resolution
base for a relative path is itself RELATIVE — e.g. ``TERMINAL_CWD="."`` from a
stale config — ``_resolve_path_for_task`` resolved the path against the agent's
PROCESS cwd instead of the intended workspace. In a git-worktree session this
silently routed ``patch``/``write_file`` edits into the *main* checkout: the
write landed, self-verified, and reported success — against the wrong file.
The agent then grepped the worktree, saw nothing, and concluded the patch tool
had silently no-op'd. It hadn't; it wrote to the wrong place.
Core invariant these tests pin:
The resolution base for a relative path MUST always be absolute. A relative
``TERMINAL_CWD`` (``.``, ``./sub``, ``..``) must be anchored deterministically,
never left to resolve against whatever the process cwd happens to be.
"""
import os
from pathlib import Path, PurePosixPath
import pytest
import tools.file_tools as ft
import tools.terminal_tool as terminal_tool
@pytest.fixture
def _isolated_cwd(tmp_path, monkeypatch):
"""Two checkouts: workspace (intended) + decoy (process cwd)."""
workspace = tmp_path / "workspace"
decoy = tmp_path / "decoy"
workspace.mkdir()
decoy.mkdir()
(workspace / "target.py").write_text("WORKSPACE_ORIGINAL\n")
(decoy / "target.py").write_text("DECOY_ORIGINAL\n")
# Process cwd = decoy, analogous to "main repo" while the terminal is in
# the worktree.
monkeypatch.chdir(decoy)
# No session cwd recorded yet (fresh-session condition).
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
return workspace, decoy
def test_relative_terminal_cwd_anchors_to_absolute_not_process_cwd(_isolated_cwd, monkeypatch):
"""TERMINAL_CWD='.' must NOT silently mean 'the agent process cwd'.
A relative base is meaningless as a resolution anchor. The resolver must
make it absolute deterministically. We assert the resolved path is
absolute and stable regardless of where os.getcwd() points.
"""
workspace, decoy = _isolated_cwd
# Poison config: literal relative '.'
monkeypatch.setenv("TERMINAL_CWD", ".")
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute(), f"resolution base leaked a relative path: {resolved}"
# The exact anchor for a bare '.' is the process cwd resolved to absolute —
# that is acceptable as long as it is ABSOLUTE and stable. The bug was that
# a relative base produced surprising results; the fix is that the base is
# always absolutised. (We do not require it to point at the workspace here —
# that's what live-cwd tracking is for; see the next test.)
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
def test_live_tracking_cwd_wins_over_relative_terminal_cwd(_isolated_cwd, monkeypatch):
"""When the terminal reports its absolute cwd, that is authoritative.
This is the real-world fix: the terminal's tracked absolute cwd (the
worktree) must override a stale relative TERMINAL_CWD so edits land where
the agent is actually working.
"""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
terminal_tool.record_session_cwd("default", str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_terminal_cwd_used_verbatim(_isolated_cwd, monkeypatch):
"""An absolute TERMINAL_CWD is the resolution base (no live tracking)."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_input_path_ignores_base(_isolated_cwd, monkeypatch):
"""An absolute input path is never re-anchored."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
abs_target = str(workspace / "target.py")
resolved = ft._resolve_path_for_task(abs_target, task_id="default")
assert resolved == Path(abs_target).resolve()
def test_container_absolute_input_path_does_not_follow_host_symlink(tmp_path, monkeypatch):
"""Docker paths are sandbox-local and must not be host-dereferenced.
A user may have a host symlink at a container-looking path such as
``/workspace/projects``. For Docker file ops, resolving that symlink on the
host rewrites the path before Docker sees it, making file tools and terminal
disagree about where the file lives.
"""
host_project = tmp_path / "host-project"
host_project.mkdir()
container_mount = tmp_path / "workspace-projects"
container_mount.symlink_to(host_project, target_is_directory=True)
monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: {"env_type": "docker"})
monkeypatch.setattr(terminal_tool, "_active_environments", {})
container_path = container_mount / "oilsands-sim" / "README.md"
resolved = ft._resolve_path_for_task(str(container_path), task_id="default")
assert resolved == container_path
assert resolved != (host_project / "oilsands-sim" / "README.md")
def test_container_path_normalization_uses_posix_path_syntax():
resolved = ft._normalize_without_host_deref("/workspace/projects/foo/../bar")
assert resolved == PurePosixPath("/workspace/projects/bar")
assert str(resolved) == "/workspace/projects/bar"
def test_container_relative_path_keeps_container_cwd_symlink(tmp_path, monkeypatch):
"""Relative Docker paths should stay under the container cwd textually."""
host_project = tmp_path / "host-project"
host_project.mkdir()
container_mount = tmp_path / "workspace-projects"
container_mount.symlink_to(host_project, target_is_directory=True)
monkeypatch.setattr(terminal_tool, "_get_env_config", lambda: {"env_type": "docker"})
monkeypatch.setattr(terminal_tool, "_active_environments", {})
terminal_tool.record_session_cwd("default", str(container_mount))
resolved = ft._resolve_path_for_task("oilsands-sim/README.md", task_id="default")
assert resolved == container_mount / "oilsands-sim" / "README.md"
assert resolved != host_project / "oilsands-sim" / "README.md"
class _DummyDockerEnvironment:
cwd = "/workspace"
cwd_owner = "default"
def test_container_path_detection_uses_live_docker_environment(monkeypatch):
"""A live DockerEnvironment-shaped env should beat config fallback."""
monkeypatch.setattr(
terminal_tool,
"_active_environments",
{"default": _DummyDockerEnvironment()},
)
monkeypatch.setattr(
terminal_tool,
"_get_env_config",
lambda: (_ for _ in ()).throw(AssertionError("should not read config")),
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
assert ft._uses_container_paths("default") is True
def test_resolution_base_always_absolute_no_terminal_cwd(_isolated_cwd, monkeypatch):
"""With TERMINAL_CWD unset, the base falls back to an ABSOLUTE process cwd."""
workspace, decoy = _isolated_cwd
monkeypatch.delenv("TERMINAL_CWD", raising=False)
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute()
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
# ── B-(ii): workspace-divergence warning ────────────────────────────────────
def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monkeypatch):
"""Relative path resolving outside the live workspace must warn."""
workspace, decoy = _isolated_cwd
# Live cwd = workspace, but the relative path resolves to decoy (process cwd)
# because TERMINAL_CWD is the poison '.'. Simulate by recording workspace
# as the session cwd while the resolved path is under decoy.
terminal_tool.record_session_cwd("default", str(workspace))
resolved_in_decoy = decoy / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_decoy, task_id="default")
assert warn is not None
assert "OUTSIDE the active workspace" in warn
assert str(decoy) in warn
assert str(workspace) in warn
def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
terminal_tool.record_session_cwd("default", str(workspace))
resolved_in_workspace = workspace / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_workspace, task_id="default")
assert warn is None
def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
terminal_tool.record_session_cwd("default", str(workspace))
warn = ft._path_resolution_warning(str(decoy / "target.py"), decoy / "target.py", task_id="default")
assert warn is None
def test_no_warning_when_no_live_cwd(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
monkeypatch.delenv("TERMINAL_CWD", raising=False)
warn = ft._path_resolution_warning("target.py", decoy / "target.py", task_id="default")
assert warn is None
# ── Fix C: sentinel TERMINAL_CWD + empty-registry worktree anchoring ─────────
# (May 2026 follow-up: PR #35399 made misroutes visible via resolved_path but
# the divergence warning only fired when the live terminal cwd was known. A
# worktree session whose terminal registry is still empty — no `cd` run yet —
# got neither a worktree anchor nor a warning, so a relative edit silently
# landed in main. These tests pin the sentinel handling + empty-registry
# anchoring + early warning.)
@pytest.mark.parametrize("sentinel", ["", ".", "./", "auto", "cwd", "CWD", "Auto"])
def test_sentinel_terminal_cwd_is_treated_as_unset(_isolated_cwd, monkeypatch, sentinel):
"""Sentinel TERMINAL_CWD values are NOT used as a directory anchor.
They fall through to the (absolute) process cwd, exactly as if unset —
never resolved as a literal relative directory.
"""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
monkeypatch.setenv("TERMINAL_CWD", sentinel)
assert ft._configured_terminal_cwd() is None
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute()
assert resolved == (decoy / "target.py").resolve()
def test_relative_nonsentinel_terminal_cwd_rejected(_isolated_cwd, monkeypatch):
"""A relative (but non-sentinel) TERMINAL_CWD is still rejected as an anchor.
A relative anchor is ambiguous (relative to which cwd?), which is the exact
ambiguity that misroutes edits. It must fall through to the process cwd, not
be joined onto it as a literal subdir.
"""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
monkeypatch.setenv("TERMINAL_CWD", "some/rel/path")
assert ft._configured_terminal_cwd() is None
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (decoy / "target.py").resolve()
def test_absolute_terminal_cwd_anchors_with_empty_registry(_isolated_cwd, monkeypatch):
"""The incident-preventing case: worktree session, registry still empty.
With no live terminal cwd recorded yet but an absolute TERMINAL_CWD (the
worktree path cli.py/main.py set for `-w`), a relative edit must land in the
worktree — not the process cwd (main repo).
"""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
monkeypatch.setenv("TERMINAL_CWD", str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
assert not str(resolved).startswith(str(decoy))
def test_registered_task_cwd_override_anchors_before_terminal_env_exists(_isolated_cwd, monkeypatch):
"""TUI/Desktop sessions register cwd by raw session key before tools run.
CWD-only overrides collapse to the shared terminal environment key, but the
file resolver must still read the raw task/session override before falling
back to TERMINAL_CWD or the process cwd.
"""
workspace, decoy = _isolated_cwd
task_id = "desktop-session-cwd"
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.setattr(terminal_tool, "_task_env_overrides", {})
terminal_tool.register_task_env_overrides(task_id, {"cwd": str(workspace)})
resolved = ft._resolve_path_for_task("target.py", task_id=task_id)
assert terminal_tool._resolve_container_task_id(task_id) == "default"
assert resolved == (workspace / "target.py")
assert not str(resolved).startswith(str(decoy))
def test_warning_fires_from_terminal_cwd_when_registry_empty(_isolated_cwd, monkeypatch):
"""Divergence warning must fire even before any terminal command runs.
PR #35399's warning required a live terminal cwd; a fresh worktree session
(empty registry) silently misrouted with no warning. Now the warning falls
back to the absolute TERMINAL_CWD anchor, so an edit aimed outside the
worktree is flagged on the very first write.
"""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
monkeypatch.setenv("TERMINAL_CWD", str(workspace))
# Relative path that escapes the worktree into the decoy/main checkout.
escaping = os.path.relpath(str(decoy / "target.py"), str(workspace))
resolved = ft._resolve_path_for_task(escaping, task_id="default")
warn = ft._path_resolution_warning(escaping, resolved, task_id="default")
assert warn is not None
assert "OUTSIDE the active workspace" in warn
assert str(workspace) in warn
def test_live_cwd_still_wins_over_absolute_terminal_cwd(_isolated_cwd, monkeypatch):
"""When both are present, the live terminal cwd remains authoritative."""
workspace, decoy = _isolated_cwd
other = decoy.parent / "other"
other.mkdir()
# Recorded session cwd = workspace; TERMINAL_CWD points elsewhere — record wins.
terminal_tool.record_session_cwd("default", str(workspace))
monkeypatch.setenv("TERMINAL_CWD", str(other))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
# ── Fix A: write_file / patch report the resolved ABSOLUTE path ──────────────
def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""write_file_tool must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
terminal_tool.record_session_cwd("t1", str(workspace))
import json
out = json.loads(ft.write_file_tool("newfile.txt", "hello\n", task_id="t1"))
expected = str((workspace / "newfile.txt").resolve())
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert (workspace / "newfile.txt").read_text() == "hello\n"
def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""patch_tool (replace mode) must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
terminal_tool.record_session_cwd("t1", str(workspace))
import json
out = json.loads(ft.patch_tool(
mode="replace", path="target.py",
old_string="WORKSPACE_ORIGINAL", new_string="WORKSPACE_PATCHED",
task_id="t1",
))
expected = str((workspace / "target.py").resolve())
assert not out.get("error"), out
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert "WORKSPACE_PATCHED" in (workspace / "target.py").read_text()
# And the decoy copy is untouched.
assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n"
# ── Cross-session isolation: one session's cwd never leaks into another ──────
# (June 2026 bug class: two desktop sessions, each on its own worktree, shared
# the single "default" terminal environment and could inherit each other's cwd.
# The per-session record store solves this structurally: each session's cd
# state lives in its own record, keyed by the raw session id.)
@pytest.fixture
def _two_worktree_sessions(tmp_path, monkeypatch):
"""Two worktree sessions: B has cd'd (record), both registered overrides."""
wt_a = tmp_path / "wt_a"
wt_b = tmp_path / "wt_b"
main = tmp_path / "main"
for d in (wt_a, wt_b, main):
d.mkdir()
(d / "target.py").write_text(f"{d.name}\n")
monkeypatch.chdir(main)
monkeypatch.delenv("TERMINAL_CWD", raising=False)
monkeypatch.setattr(terminal_tool, "_task_env_overrides", {})
monkeypatch.setattr(terminal_tool, "_session_cwd", {})
monkeypatch.setattr(ft, "_file_ops_cache", {})
# Both sessions register their worktree cwd (TUI/desktop registration path;
# registration seeds each session's record).
terminal_tool.register_task_env_overrides("sess-a", {"cwd": str(wt_a)})
terminal_tool.register_task_env_overrides("sess-b", {"cwd": str(wt_b)})
# Session B ran the last command; the shared env's live cwd is wt_b but
# only B's RECORD carries it.
monkeypatch.setattr(
terminal_tool,
"_active_environments",
{"default": _FakeEnv(str(wt_b))},
)
return wt_a, wt_b, main
class _FakeEnv:
def __init__(self, cwd: str):
self.cwd = cwd
def test_resolution_routes_to_resolving_sessions_worktree(_two_worktree_sessions):
"""The wrong-worktree fix: A resolves into wt_a, not the shared env's wt_b."""
wt_a, wt_b, _main = _two_worktree_sessions
resolved_a = ft._resolve_path_for_task("target.py", task_id="sess-a")
assert resolved_a == (wt_a / "target.py")
assert not str(resolved_a).startswith(str(wt_b))
def test_session_with_cd_record_resolves_against_it(_two_worktree_sessions):
"""B's record (its own cd state) is authoritative for B."""
wt_a, wt_b, _main = _two_worktree_sessions
resolved_b = ft._resolve_path_for_task("target.py", task_id="sess-b")
assert resolved_b == (wt_b / "target.py")
assert not str(resolved_b).startswith(str(wt_a))
def test_sessions_cd_updates_only_its_own_resolution(_two_worktree_sessions, tmp_path):
"""B cd's elsewhere → B's resolution follows, A's is untouched."""
wt_a, wt_b, _main = _two_worktree_sessions
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
terminal_tool.record_session_cwd("sess-b", str(elsewhere))
assert ft._resolve_path_for_task("f.py", task_id="sess-b") == (elsewhere / "f.py")
assert ft._resolve_path_for_task("f.py", task_id="sess-a") == (wt_a / "f.py")
def test_unregistered_session_never_inherits_another_sessions_record(
_two_worktree_sessions, monkeypatch
):
"""Session C: no record, no override. Must NOT inherit A's or B's cwd."""
wt_a, wt_b, main = _two_worktree_sessions
resolved = ft._resolve_path_for_task("target.py", task_id="sess-c")
assert not str(resolved).startswith(str(wt_a))
assert not str(resolved).startswith(str(wt_b))
assert resolved == (main / "target.py").resolve()