fix(tools): isolate per-session cwd so worktree sessions don't cross

Two desktop sessions on different worktrees share the single "default"
terminal env, whose cwd tracks whichever session ran the last command. A
file/patch from the OTHER session then resolved against that foreign cwd and
silently wrote into the wrong worktree.

terminal_tool now stamps env.cwd_owner with the session driving each command
(fg + bg), and file_tools trusts the shared env's live cwd only when the
resolving session owns it — otherwise it falls back to that session's own
registered cwd override. Unknown/"default" owners keep prior single-session
behavior.
This commit is contained in:
Brooklyn Nicholson
2026-06-23 13:17:43 -05:00
parent baea9a6c79
commit 3f2e41eb10
3 changed files with 125 additions and 12 deletions

View File

@@ -314,3 +314,83 @@ def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
assert "WORKSPACE_PATCHED" in (workspace / "target.py").read_text()
# And the decoy copy is untouched.
assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n"
# ── Fix D: shared terminal env must not leak its cwd across worktree sessions ─
# (June 2026: two desktop sessions, each on its own worktree, share the single
# "default" terminal environment. Its `cwd` tracks whichever session ran the
# last command, so a file edit from the OTHER session resolved against that
# foreign cwd and silently landed in the wrong worktree. terminal_tool now
# stamps env.cwd_owner with the driving session; file tools trust the shared
# env's live cwd only when the resolving session owns it.)
class _FakeOwnedEnv:
def __init__(self, cwd: str, cwd_owner: str):
self.cwd = cwd
self.cwd_owner = cwd_owner
@pytest.fixture
def _two_worktree_sessions(tmp_path, monkeypatch):
"""Two worktree sessions sharing one terminal env owned by session B."""
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(ft, "_file_ops_cache", {})
# Both sessions register their worktree cwd (TUI/desktop registration path).
terminal_tool.register_task_env_overrides("sess-a", {"cwd": str(wt_a)})
terminal_tool.register_task_env_overrides("sess-b", {"cwd": str(wt_b)})
# The shared "default" env: session B ran the last command, so its live cwd
# is wt_b and B owns it.
monkeypatch.setattr(
terminal_tool,
"_active_environments",
{"default": _FakeOwnedEnv(str(wt_b), "sess-b")},
)
return wt_a, wt_b, main
def test_live_cwd_ignored_for_non_owning_session(_two_worktree_sessions):
wt_a, wt_b, _main = _two_worktree_sessions
# Owner sees the live cwd; the other session must NOT inherit it.
assert ft._get_live_tracking_cwd("sess-b") == str(wt_b)
assert ft._get_live_tracking_cwd("sess-a") is None
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
# Session A does not own the shared env → falls back to its own registered
# worktree cwd instead of B's live cwd.
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_owning_session_still_resolves_against_live_cwd(_two_worktree_sessions):
"""No regression: the owner keeps resolving against the live cwd."""
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_unknown_owner_keeps_prior_single_session_behavior(tmp_path, monkeypatch):
"""An env with no owner (CLI / legacy) still yields its live cwd."""
ws = tmp_path / "ws"
ws.mkdir()
monkeypatch.setattr(ft, "_file_ops_cache", {})
monkeypatch.setattr(
terminal_tool,
"_active_environments",
{"default": _FakeOwnedEnv(str(ws), "")},
)
assert ft._get_live_tracking_cwd("default") == str(ws)
assert ft._get_live_tracking_cwd("any-session") == str(ws)

View File

@@ -166,6 +166,30 @@ def _registered_task_cwd_override(task_id: str = "default") -> str | None:
return _sentinel_free_abs_cwd(overrides.get("cwd"))
def _live_cwd_if_owned(env, task_id: str) -> str | None:
"""The env's live cwd, but only when THIS session owns it.
The terminal env is shared (collapsed to the ``"default"`` container), so its
``cwd`` tracks the LAST session that ran a command. With two worktree
sessions open, trusting it blindly routes one session's edits into the other
session's checkout (the wrong-worktree-patch bug). ``terminal_tool`` stamps
``env.cwd_owner`` with the session that last drove the env; return its cwd
only when that owner matches the resolving session, else ``None`` so the
caller falls through to this session's own registered cwd override. Unknown
owner / ``default`` keys keep the prior behavior (single-session / CLI).
"""
if env is None:
return None
live = getattr(env, "cwd", None)
if not live:
return None
owner = str(getattr(env, "cwd_owner", "") or "")
tid = str(task_id or "")
if owner and tid and owner != "default" and tid != "default" and owner != tid:
return None
return live
def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
"""Return the task's live terminal cwd for bookkeeping when available."""
try:
@@ -177,18 +201,20 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
with _file_ops_lock:
cached = _file_ops_cache.get(container_key) or _file_ops_cache.get(task_id)
if cached is not None:
live_cwd = getattr(getattr(cached, "env", None), "cwd", None) or getattr(
cached, "cwd", None
)
env = getattr(cached, "env", None)
live_cwd = _live_cwd_if_owned(env, task_id)
if live_cwd:
return live_cwd
# Legacy: a cache entry carrying its own cwd with no env to own it.
if env is None and getattr(cached, "cwd", None):
return getattr(cached, "cwd", None)
try:
from tools.terminal_tool import _active_environments, _env_lock
with _env_lock:
env = _active_environments.get(container_key) or _active_environments.get(task_id)
live_cwd = getattr(env, "cwd", None) if env is not None else None
live_cwd = _live_cwd_if_owned(env, task_id)
if live_cwd:
return live_cwd
except Exception:

View File

@@ -2144,20 +2144,27 @@ def terminal_tool(
"EOF."
)
# Claim the (shared "default") terminal env for the session driving this
# command. File tools read env.cwd_owner to decide whether the env's live
# cwd is THIS session's `cd` or a different worktree session's — without
# it, two open worktree sessions sharing the env route each other's edits
# to the wrong checkout. get_current_session_key()'s contextvar doesn't
# cross tool-worker threads, so fall back to the raw task_id (which IS the
# session_key for the top-level agent) — a stable, thread-safe anchor.
from tools.approval import get_current_session_key
session_key = get_current_session_key(default="") or (task_id or "")
try:
env.cwd_owner = session_key
except Exception:
pass
if background:
# Spawn a tracked background process via the process registry.
# For local backends: uses subprocess.Popen with output buffering.
# For non-local backends: runs inside the sandbox via env.execute().
from tools.approval import get_current_session_key
from tools.process_registry import process_registry
# get_current_session_key() reads a contextvar that does NOT
# propagate to concurrent tool-worker threads, so a backgrounded
# command spawned off the main turn thread would record an empty
# session_key — and then `process.kill` / stop can't find it to kill
# it (rogue process). The raw `task_id` IS the session_key for the
# top-level agent, so fall back to it: a stable, thread-safe anchor.
session_key = get_current_session_key(default="") or (task_id or "")
effective_cwd = _resolve_command_cwd(
workdir=workdir,
env=env,