From 3f8b22004958506e9d476989365b3ee2ced4ecff Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 10 Jul 2026 01:54:51 -0500 Subject: [PATCH] fix(tools): resolve MSYS paths in file tools on Windows Git Bash hands file tools paths like /c/Users/... which Path() on native Windows treats as relative \\c\\Users\\... under the process cwd. Reuse local._msys_to_windows_path (extended for /cygdrive and /mnt drive forms) in _resolve_path_for_task / _resolve_base_dir so read/write/search land on the real drive. Container/WSL Linux paths are left untouched. Salvages #50488 (drops unrelated desktop artifact commit); tests adapted from #46995. Co-authored-by: Jeff Watts <186512915+lEWFkRAD@users.noreply.github.com> Co-authored-by: LeonSGP43 --- tests/tools/test_file_tools.py | 63 ++++++++++++++++++++++ tests/tools/test_local_env_windows_msys.py | 9 ++++ tools/environments/local.py | 12 ++++- tools/file_tools.py | 31 ++++++++++- 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 36918417938..f57d5282103 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -427,6 +427,69 @@ class TestSearchHandler: assert "error" in result +# --------------------------------------------------------------------------- +# Windows MSYS path resolution (salvage of #50488 / #46995) +# --------------------------------------------------------------------------- + +class TestWindowsMsysPathResolution: + """File tools must translate Git Bash drive paths before Path resolution.""" + + def test_absolute_msys_path_normalized_before_windows_resolve(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/c/Users/Mark/project/app.py") + assert str(resolved) == r"C:\Users\Mark\project\app.py" + + def test_cygdrive_path_normalized(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + + resolved = file_tools._resolve_path_for_task("/cygdrive/d/code/main.py") + assert str(resolved) == r"D:\code\main.py" + + def test_relative_path_uses_normalized_msys_cwd(self, monkeypatch): + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": False) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/c/Users/Mark/project", + ) + + resolved = file_tools._resolve_path_for_task("src/app.py", task_id="msys") + assert str(resolved) == r"C:\Users\Mark\project\src\app.py" + + def test_container_paths_skip_msys_translation(self, monkeypatch): + """WSL/docker Linux paths must not be rewritten as Windows drives.""" + import tools.environments.local as local_mod + import tools.file_tools as file_tools + + monkeypatch.setattr(file_tools.sys, "platform", "win32") + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + monkeypatch.setattr(file_tools, "_uses_container_paths", lambda task_id="default": True) + monkeypatch.setattr( + file_tools, + "_authoritative_workspace_root", + lambda task_id="default": "/home/don/project", + ) + + resolved = file_tools._resolve_path_for_task("/home/don/.env") + assert str(resolved) == "/home/don/.env" + + # --------------------------------------------------------------------------- # Tool result hint tests (#722) # --------------------------------------------------------------------------- diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 59f01ac56b6..3e35db414cf 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -68,6 +68,15 @@ class TestMsysToWindowsPath: monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) assert _msys_to_windows_path("/tmp/foo") == "/tmp/foo" assert _msys_to_windows_path("/home/x") == "/home/x" + # /mnt//... only translates when is a single drive letter. + assert _msys_to_windows_path("/mnt/home/x") == "/mnt/home/x" + + def test_translates_cygdrive_and_wsl_mnt_forms(self, monkeypatch): + monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) + assert _msys_to_windows_path("/cygdrive/c/Users/NVIDIA") == r"C:\Users\NVIDIA" + assert _msys_to_windows_path("/mnt/d/Projects/foo") == r"D:\Projects\foo" + assert _msys_to_windows_path("/cygdrive/c") == "C:\\" + assert _msys_to_windows_path("/mnt/c/") == "C:\\" def test_empty_string(self, monkeypatch): monkeypatch.setattr(local_mod, "_IS_WINDOWS", True) diff --git a/tools/environments/local.py b/tools/environments/local.py index 191ff4d4b2d..26d0b2a454c 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -25,16 +25,24 @@ def _msys_to_windows_path(cwd: str) -> str: native Windows form (``C:\\Users\\x``) so ``os.path.isdir`` and ``subprocess.Popen(..., cwd=...)`` can find it. + Also accepts the Cygwin (``/cygdrive/c/...``) and WSL-mount + (``/mnt/c/...``) spellings of a drive root. Multi-segment POSIX paths + like ``/home/x`` or ``/tmp/foo`` are left untouched. + 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 "//" or exactly "/" (bare drive root). - m = re.match(r'^/([a-zA-Z])(/.*)?$', cwd) + # Match leading "//" or exactly "/" (bare drive root), + # plus /cygdrive//... and /mnt//... variants. + m = re.match(r'^/(?:(?:cygdrive|mnt)/)?([a-zA-Z])(/.*)?$', cwd) if not m: return cwd + # Reject /cygdrive or /mnt with no drive letter — the optional group above + # already requires the letter. Multi-char first segments (/home, /tmp) + # fail the single-letter capture and fall through as no-ops. 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 diff --git a/tools/file_tools.py b/tools/file_tools.py index 61e6ecc588d..e602e8e0a67 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -6,6 +6,7 @@ import json import logging import os import posixpath +import sys import threading from pathlib import Path, PurePosixPath @@ -406,6 +407,17 @@ def _resolve_base_dir( if not posixpath.isabs(base_text): base_text = posixpath.join(os.getcwd(), base_text) return _normalize_without_host_deref(base_text) + # Git Bash ``pwd -P`` reports ``/c/Users/...``; translate before Path so + # relative file-tool paths don't anchor under a nonexistent ``\\c\\Users``. + from tools.environments.local import _msys_to_windows_path + + base_text = _msys_to_windows_path(base_text) + if sys.platform == "win32": + import ntpath + + if not ntpath.isabs(base_text): + base_text = ntpath.join(os.getcwd(), base_text) + return Path(ntpath.normpath(base_text)) base = Path(base_text) if not base.is_absolute(): # Last-resort anchoring: a live cwd should already be absolute, but if a @@ -420,14 +432,31 @@ def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path | Pu See :func:`_resolve_base_dir` for how the base is chosen. Absolute input paths are returned resolved-but-unanchored. + + On native Windows, Git Bash / MSYS drive paths (``/c/Users/...``) are + translated to ``C:\\Users\\...`` before resolution so file tools don't + treat them as relative ``\\c\\Users\\...`` under the process cwd. """ container_paths = _uses_container_paths(task_id) - expanded = _expand_tilde(filepath) if container_paths: + expanded = _expand_tilde(filepath) if posixpath.isabs(expanded): return _normalize_without_host_deref(expanded) resolved = _resolve_base_dir(task_id, container_paths=True) / expanded return _normalize_without_host_deref(resolved) + + # Host paths only — never rewrite Linux paths inside a container/WSL env. + from tools.environments.local import _msys_to_windows_path + + expanded = _expand_tilde(_msys_to_windows_path(filepath)) + if sys.platform == "win32": + import ntpath + + if ntpath.isabs(expanded): + return Path(ntpath.normpath(expanded)) + joined = ntpath.join(str(_resolve_base_dir(task_id, container_paths=False)), expanded) + return Path(ntpath.normpath(joined)) + p = Path(expanded) if p.is_absolute(): return p.resolve()