From 6f7698bf66f8b2596b1ebd2efb0fb68c57b3afdb Mon Sep 17 00:00:00 2001 From: Frowtek Date: Sat, 13 Jun 2026 08:28:05 +0300 Subject: [PATCH] fix(agent): guard coding-context cwd fallback against getcwd errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolve_cwd() fell back to Path(os.getcwd()) when resolve_agent_cwd() raised. But resolve_agent_cwd() raises precisely because os.getcwd() failed (a deleted CWD with no TERMINAL_CWD/session override), so the fallback re-invoked the same failing syscall and propagated out of resolve_runtime_mode() and build_coding_workspace_block(). Path.resolve() also calls os.getcwd() for absolute paths on some platforms, making _git_root/_marker_root/_home and the worktree probe additional crash sites. Add _safe_resolve() — a resolve() with a getcwd-free normpath fallback on OSError — and use it at every resolve() in the resolution path. Degrade _resolve_cwd() to home, then the temp dir, when getcwd fails, so posture detection settles on general/no-workspace instead of crashing prompt build. resolve_agent_cwd()'s documented OSError contract is unchanged; this only fixes its caller-side guard. Adds TestDeletedCwdTolerance covering posture resolution and workspace block building under a raising os.getcwd(), plus the explicit-cwd and home/temp fallback paths. --- agent/coding_context.py | 39 ++++++++++++-- tests/agent/test_coding_context.py | 82 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/agent/coding_context.py b/agent/coding_context.py index ede0dc1528ab..72b77f36b557 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -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 @@ -298,7 +299,33 @@ 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: @@ -306,11 +333,15 @@ def _resolve_cwd(cwd: Optional[str | Path]) -> Path: 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 @@ -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 @@ -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: @@ -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 ( diff --git a/tests/agent/test_coding_context.py b/tests/agent/test_coding_context.py index 00d1eaa3e51d..9a808989336c 100644 --- a/tests/agent/test_coding_context.py +++ b/tests/agent/test_coding_context.py @@ -4,6 +4,7 @@ import os import subprocess import shutil +import tempfile from pathlib import Path import pytest @@ -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())