fix(windows): stop spamming cwd-missing + tirith-spawn warnings on every terminal call

Two log-spam fixes surfaced by a Windows user (Git Bash + Python 3.11.9):

1. LocalEnvironment cwd warn spam
   ============================
   Git Bash's `pwd -P` emits paths like `/c/Users/x`. The base-class
   `_extract_cwd_from_output` was assigning this verbatim to `self.cwd`
   without validation, then `_resolve_safe_cwd`'s `os.path.isdir(/c/...)`
   returned False on Windows, triggering:

       LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk;
       falling back to '/' so terminal commands keep working.

   ...on every terminal call. The pre-existing Windows-path translation
   inside `_run_bash` ran AFTER the safe-cwd check, so it could never
   prevent the warning.

   Fix:
   - New `_msys_to_windows_path` helper (idempotent, no-op off Windows).
   - `_resolve_safe_cwd` normalizes before `isdir`, so a valid MSYS path
     is recognized as the real directory it points at.
   - `LocalEnvironment._update_cwd` and a new override of
     `_extract_cwd_from_output` translate + validate before mutating
     `self.cwd`. Stale / non-existent marker paths roll back to the
     previous cwd instead of clobbering it.
   - The fallback warning still fires when the directory really is gone
     (deletion-recovery scenario from #17558 still covered).

2. tirith spawn-failed warn spam
   =============================
   When tirith isn't installed (background install in flight, or marked
   failed for the day) and the configured path stays as the bare string
   `tirith`, every `subprocess.run([tirith_path, ...])` raises OSError
   and logged:

       tirith spawn failed: [WinError 2] The system cannot find the file specified

   ...on every command. fail_open=True means behaviour is correct, but
   the log noise is severe.

   Fix:
   - `_warn_once(key, ...)` thread-safe dedupe helper.
   - Three hot-path warnings (`tirith path resolved to None`,
     `tirith spawn failed: ...`, `tirith timed out after Ns`) now log
     once per (exception class, errno) / timeout-value / path-none key.
   - Dedupe set is cleared on `_clear_install_failed` so a successful
     install lets a subsequent failure surface again.

Tests
=====
- `tests/tools/test_local_env_windows_msys.py`: 12 tests covering the
  MSYS→Windows translator, the resolve fast-path, update_cwd validation,
  and extract_cwd_from_output rollback.
- `tests/tools/test_tirith_security.py`: 4 new dedupe tests (15 spawn
  failures → 1 log line; distinct exc types → 2 lines; timeout dedupe;
  path-None dedupe).

Targeted runs:
  test_local_env_windows_msys.py      12 passed
  test_local_env_cwd_recovery.py       7 passed (pre-existing, no regressions)
  test_tirith_security.py             67 passed (63 pre-existing + 4 new)
  test_base_environment + local_*    37 passed (no regressions)
  test_local_env_blocklist + neighbours  114 passed

Reported via Hermes log capture: 19× cwd warnings + 15× tirith warnings
in a single short session.
This commit is contained in:
teknium1 2026-05-15 14:58:28 -07:00 committed by Teknium
parent 7fee1f61eb
commit 4aec25bc44
4 changed files with 441 additions and 14 deletions

View file

@ -18,18 +18,44 @@ _IS_WINDOWS = platform.system() == "Windows"
logger = logging.getLogger(__name__)
def _msys_to_windows_path(cwd: str) -> str:
"""Translate a Git Bash / MSYS-style POSIX path (``/c/Users/x``) to the
native Windows form (``C:\\Users\\x``) so ``os.path.isdir`` and
``subprocess.Popen(..., cwd=...)`` can find it.
No-ops on non-Windows hosts or for paths that aren't in MSYS form.
Returns the input unchanged when no translation applies. This is
idempotent calling it on an already-Windows path returns it as-is.
"""
if not _IS_WINDOWS or not cwd:
return cwd
# Match leading "/<single letter>/" or exactly "/<letter>" (bare drive root).
m = re.match(r'^/([a-zA-Z])(/.*)?$', cwd)
if not m:
return cwd
drive = m.group(1).upper()
tail = (m.group(2) or "").replace('/', '\\')
return f"{drive}:{tail or chr(92)}" # chr(92) = backslash, avoid raw-string escape
def _resolve_safe_cwd(cwd: str) -> str:
"""Return ``cwd`` if it exists as a directory, else the nearest existing
ancestor. Falls back to ``tempfile.gettempdir()`` only if walking up the
path can't find any existing directory (effectively never on a healthy
filesystem, but cheap belt-and-braces).
On Windows, also normalizes Git Bash / MSYS-style POSIX paths
(``/c/Users/x``) to native Windows form before the isdir check so a
perfectly valid ``pwd -P`` result from bash doesn't get rejected as
"missing" (see ``_msys_to_windows_path``).
Used by ``_run_bash`` to recover when the configured cwd is gone most
commonly because a previous tool call deleted its own working directory
(issue #17558). Without this guard, ``subprocess.Popen(..., cwd=...)``
raises ``FileNotFoundError`` before bash starts, wedging every subsequent
terminal call until the gateway restarts.
"""
cwd = _msys_to_windows_path(cwd) if _IS_WINDOWS else cwd
if cwd and os.path.isdir(cwd):
return cwd
parent = os.path.dirname(cwd) if cwd else ""
@ -455,21 +481,27 @@ class LocalEnvironment(BaseEnvironment):
# (issue #17558). Popen would otherwise raise FileNotFoundError on
# the cwd before bash starts, wedging every subsequent call until the
# gateway restarts.
#
# On Windows, ``_resolve_safe_cwd`` also normalises Git Bash-style
# POSIX paths (``/c/Users/...``) to native form so a perfectly valid
# ``pwd -P`` result from bash isn't mistakenly treated as "missing"
# and spammed as a warning on every command.
safe_cwd = _resolve_safe_cwd(self.cwd)
if safe_cwd != self.cwd:
logger.warning(
"LocalEnvironment cwd %r is missing on disk; "
"falling back to %r so terminal commands keep working.",
self.cwd,
safe_cwd,
)
# MSYS → Windows translation alone shouldn't surface as a warning
# (it's a benign normalization, not a recovery). Only warn when
# the directory really doesn't exist on disk.
normalized = _msys_to_windows_path(self.cwd) if _IS_WINDOWS else self.cwd
if safe_cwd != normalized:
logger.warning(
"LocalEnvironment cwd %r is missing on disk; "
"falling back to %r so terminal commands keep working.",
self.cwd,
safe_cwd,
)
self.cwd = safe_cwd
# On Windows, self.cwd may be a Git Bash-style path (/c/Users/...)
# from pwd output. subprocess.Popen needs a native Windows path.
_popen_cwd = self.cwd
if _IS_WINDOWS and _popen_cwd and re.match(r'^/[a-zA-Z]/', _popen_cwd):
_popen_cwd = _popen_cwd[1].upper() + ':' + _popen_cwd[2:].replace('/', '\\')
proc = subprocess.Popen(
args,
@ -571,10 +603,19 @@ class LocalEnvironment(BaseEnvironment):
``pwd -P`` on a deleted cwd can leave a stale value in the marker
file, and propagating it would re-wedge the next ``Popen``. The
``_run_bash`` recovery path will resolve a safe fallback if needed.
On Windows, the value written by Git Bash's ``pwd -P`` is in
MSYS form (``/c/Users/x``). Translate it to native Windows form
before validating with ``os.path.isdir`` and before storing on
``self.cwd``; otherwise the isdir check rejects every valid
result and ``_run_bash`` later prints a misleading "cwd is
missing" warning on every command.
"""
try:
with open(self._cwd_file, encoding="utf-8") as f:
cwd_path = f.read().strip()
if _IS_WINDOWS:
cwd_path = _msys_to_windows_path(cwd_path)
if cwd_path and os.path.isdir(cwd_path):
self.cwd = cwd_path
except (OSError, FileNotFoundError):
@ -583,6 +624,30 @@ class LocalEnvironment(BaseEnvironment):
# Still strip the marker from output so it's not visible
self._extract_cwd_from_output(result)
def _extract_cwd_from_output(self, result: dict):
"""Same semantics as the base class, but on Windows the value
emitted by ``pwd -P`` inside Git Bash is in MSYS form
(``/c/Users/x``). Normalize to native Windows form and validate
the directory exists before assigning to ``self.cwd`` otherwise
``_run_bash``'s safe-cwd recovery would warn on every subsequent
command.
Always defers to the base class for stripping the marker text from
``result["output"]`` so output formatting is identical.
"""
# Snapshot pre-existing cwd, defer to base for parsing + marker
# stripping, then validate / normalize whatever it assigned.
prev_cwd = self.cwd
super()._extract_cwd_from_output(result)
if self.cwd != prev_cwd:
normalized = _msys_to_windows_path(self.cwd) if _IS_WINDOWS else self.cwd
if normalized and os.path.isdir(normalized):
self.cwd = normalized
else:
# Stale / non-existent path — keep previous cwd; _run_bash
# will resolve a safe fallback on the next call if needed.
self.cwd = prev_cwd
def cleanup(self):
"""Clean up temp files."""
for f in (self._snapshot_path, self._cwd_file):