Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions agent/coding_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
Expand Down Expand Up @@ -298,19 +299,49 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
return "auto"


def _safe_resolve(path: Path) -> Path:
"""``Path.resolve()`` that tolerates a vanished/inaccessible CWD.

On Windows ``resolve()`` calls ``os.getcwd()`` even for *absolute* paths, so
a deleted or unreachable CWD makes it raise ``OSError`` (POSIX only re-reads
the CWD for *relative* inputs). Posture detection runs on every prompt build
and must degrade rather than crash, so fall back to a getcwd-free
normalization. Behaviour is unchanged whenever ``resolve()`` succeeds.
"""
try:
return path.resolve()
except OSError:
return Path(os.path.normpath(path))


def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
"""Resolve the cwd posture detection runs against — never raises.

``resolve_agent_cwd()`` deliberately *propagates* ``OSError`` when the
process CWD has been deleted (no ``TERMINAL_CWD``/session override); by
contract the caller owns the guard (see ``tests/agent/test_runtime_cwd.py``
and ``build_environment_hints`` in ``agent/prompt_builder.py``). This is
that guard. Re-running ``os.getcwd()`` in the fallback would re-raise the
very same error, so a deleted CWD degrades to an absolute, getcwd-free
directory (home, then the temp dir) that detection reads as "not a
workspace" — posture settles on general instead of crashing prompt build.
"""
if cwd:
return Path(cwd).expanduser()
try:
from agent.runtime_cwd import resolve_agent_cwd

return resolve_agent_cwd()
except Exception:
pass
try:
return Path(os.getcwd())
except OSError:
return _home() or Path(tempfile.gettempdir())


def _git_root(cwd: Path) -> Optional[Path]:
current = cwd.resolve()
current = _safe_resolve(cwd)
for parent in [current, *current.parents]:
if (parent / ".git").exists():
return parent
Expand All @@ -319,7 +350,7 @@ def _git_root(cwd: Path) -> Optional[Path]:

def _home() -> Optional[Path]:
try:
return Path.home().resolve()
return _safe_resolve(Path.home())
except (OSError, RuntimeError):
return None

Expand All @@ -332,7 +363,7 @@ def _marker_root(cwd: Path) -> Optional[Path]:
Makefile or AGENTS.md sitting in the home directory is global user config,
not a project-root signal.
"""
current = cwd.resolve()
current = _safe_resolve(cwd)
home = _home()
for depth, parent in enumerate([current, *current.parents]):
if depth > 6:
Expand Down Expand Up @@ -720,7 +751,7 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
# giving the model a second absolute path causes it to sometimes run commands
# in the wrong directory.
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
if git_dir and common_dir and _safe_resolve(Path(git_dir)) != _safe_resolve(Path(common_dir)):
lines.append("- Worktree: linked (git state shared with primary tree)")

dirty = [f"{n} {label}" for label, n in (
Expand Down
82 changes: 82 additions & 0 deletions tests/agent/test_coding_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import subprocess
import shutil
import tempfile
from pathlib import Path

import pytest
Expand Down Expand Up @@ -449,3 +450,84 @@ def test_marker_in_parent_counts_from_subdir(self, tmp_path):
def test_bare_dir_is_not_coding(self, tmp_path):
cfg = {"agent": {"coding_context": "auto"}}
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False


# ── deleted-CWD tolerance (caller-side getcwd guard) ─────────────────────────

class TestDeletedCwdTolerance:
"""resolve_agent_cwd() propagates OSError when the process CWD is deleted
(no TERMINAL_CWD / session override) — by contract coding_context owns the
guard. The fallback must NOT re-call os.getcwd(): that re-raises the same
error and blows up posture resolution / workspace-block building.
"""

@staticmethod
def _kill_getcwd(monkeypatch):
def _raise(*args, **kwargs):
raise FileNotFoundError("cwd gone")

monkeypatch.delenv("TERMINAL_CWD", raising=False)
# os is the same module object across coding_context and runtime_cwd,
# so this kills os.getcwd() everywhere — exactly what a deleted CWD does.
monkeypatch.setattr(os, "getcwd", _raise)

def test_resolve_runtime_mode_survives_getcwd_failure(self, monkeypatch, tmp_path):
# Home is a bare dir (no git/markers) so detection deterministically
# settles on general — and, critically, never raises.
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
self._kill_getcwd(monkeypatch)
mode = cc.resolve_runtime_mode(
platform="cli", config={"agent": {"coding_context": "auto"}}
)
assert mode.is_coding is False
assert mode.kind == "general"

def test_resolve_runtime_mode_off_survives_getcwd_failure(self, monkeypatch):
# The candidate's exact repro: off-mode posture resolution must not raise.
self._kill_getcwd(monkeypatch)
mode = cc.resolve_runtime_mode(
platform="cli", config={"agent": {"coding_context": "off"}}
)
assert mode.is_coding is False

def test_build_workspace_block_survives_getcwd_failure(self, monkeypatch, tmp_path):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
self._kill_getcwd(monkeypatch)
# No workspace can be detected for a vanished CWD → empty block, no raise.
assert cc.build_coding_workspace_block() == ""

def test_explicit_cwd_unaffected_by_getcwd_failure(self, monkeypatch, tmp_path):
# An explicit cwd never touches os.getcwd(), so a dead getcwd is irrelevant.
_git_init(tmp_path)
self._kill_getcwd(monkeypatch)
mode = cc.resolve_runtime_mode(
platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "auto"}}
)
assert mode.is_coding is True
assert "Branch: main" in cc.build_coding_workspace_block(tmp_path)

def test_resolve_cwd_degrades_to_home(self, monkeypatch, tmp_path):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: home)
self._kill_getcwd(monkeypatch)
# Degrades to home (not the temp dir) — compared via the same getcwd-free
# resolve the production path uses, so the assertion holds on Windows too
# (where even Path.resolve() of an absolute path would call os.getcwd()).
result = cc._resolve_cwd(None)
assert result == cc._home()
assert result.name == "home"

def test_resolve_cwd_degrades_to_tempdir_when_home_also_dead(self, monkeypatch):
def _no_home():
raise RuntimeError("no home")

monkeypatch.setattr(Path, "home", _no_home)
self._kill_getcwd(monkeypatch)
result = cc._resolve_cwd(None)
assert result.is_absolute()
assert result == Path(tempfile.gettempdir())
Loading