From 5d7d849f578ca4137d7f28935d34eaae8410242d Mon Sep 17 00:00:00 2001 From: Stellarrysss Date: Tue, 21 Apr 2026 10:50:07 +0300 Subject: [PATCH] fix(terminal): self-heal stale cwd to prevent exit 126 wedge on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal tool's command wrapper prefixes every user command with `cd '' || exit 126`. When `self.cwd` points at a directory that has been deleted (temp build artefact, checkpoint cleanup, or an intermediate path from an earlier `cd ... && rm -rf `), the cd fails and the script exits 126 before running anything. Because the `pwd -P > ` line runs *after* the eval, the cwd file is never refreshed, so `self.cwd` stays wedged on the bad path and every subsequent `execute()` produces the same 126 — until the process restarts. This manifests as the well-known "every terminal command returns exit 126" bug on Windows, where Git Bash path translation and temp directory cleanup together make stale-cwd scenarios especially common. Fix is two complementary layers: 1. Python-side self-heal in `BaseEnvironment.execute()`. Before each command, `_is_cwd_stale(effective_cwd)` checks whether the path can be stat'd. If stale, `self.cwd` is reset to the newly-captured `self._startup_cwd` and a warning is logged. On Windows we handle both `C:\Users\...` and Git Bash `/c/Users/...` forms via a small `_posix_drive_to_win` helper; POSIX-only paths like `/tmp/...` on Windows (whose MSYS mapping we don't resolve in Python) fall through to the second layer. 2. Shell-side fallback in `_wrap_command`. The single `cd '' || exit 126` becomes `cd '' 2>/dev/null || { cd '' 2>/dev/null && echo '...' >&2; } || exit 126`. If the Python check missed the stale path (e.g. race with an external cleanup, or a POSIX-only path), bash falls back to the startup cwd and emits a stderr breadcrumb so the caller sees what happened. Only exit 126 if *both* cwds fail. Non-breaking on Linux/Mac: `_is_cwd_stale` uses `os.path.isdir` there; shell fallback is a superset of the prior behaviour. Tests (Windows, Python 3.12): - `_posix_drive_to_win`: /c/Users conv, /d/foo conv, /tmp/x unchanged (pass) - `_is_cwd_stale`: empty=stale, live=ok, nonexistent Win=stale, `/c/`=stale, `/tmp/`=not-declared (pass) - integration: fresh env `echo hello` → rc=0 (pass) - integration: cd into tempdir, delete tempdir externally, run `echo recovered` → rc=0, self.cwd reset to startup (pass) - integration: force `self.cwd = /tmp/` (Python can't verify) → shell fallback kicks in, rc=0 (pass) - integration: consecutive commands after self-heal remain stable (pass) --- tools/environments/base.py | 62 +++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/tools/environments/base.py b/tools/environments/base.py index 19a637901a5e..758a725e477a 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -38,6 +38,38 @@ # agent.log regardless of quiet-mode. Scoped to the opt-in case only. logger.setLevel(logging.INFO) +_IS_WINDOWS = os.name == "nt" + + +def _posix_drive_to_win(path: str) -> str: + """Convert Git Bash /c/Users/... to C:/Users/... so Python can stat it. + + Returns the path unchanged when it doesn't match the //... shape. + """ + if len(path) >= 3 and path[0] == "/" and path[2] == "/" and path[1].isalpha(): + return f"{path[1].upper()}:/{path[3:]}" + return path + + +def _is_cwd_stale(cwd: str) -> bool: + """Best-effort check: is *cwd* a path whose directory no longer exists? + + Conservative on Windows: only reports stale when we can resolve the path + to a Windows form Python can stat. POSIX-only paths like /tmp/... on + Windows (whose Git Bash mapping we don't resolve here) return False — + the shell-side fallback in _wrap_command catches those. + """ + if not cwd: + return True + if _IS_WINDOWS: + if len(cwd) >= 2 and cwd[1] == ":": + return not os.path.isdir(cwd) + if len(cwd) >= 3 and cwd[0] == "/" and cwd[2] == "/" and cwd[1].isalpha(): + return not os.path.isdir(_posix_drive_to_win(cwd)) + return False + return not os.path.isdir(cwd) + + # Thread-local activity callback. The agent sets this before a tool call so # long-running _wait_for_process loops can report liveness to the gateway. _activity_callback_local = threading.local() @@ -289,6 +321,9 @@ def get_temp_dir(self) -> str: def __init__(self, cwd: str, timeout: int, env: dict = None): self.cwd = cwd + # Captured at construction — used as fallback when self.cwd gets wedged + # on a deleted/unreachable dir (see _wrap_command + execute stale-check). + self._startup_cwd = cwd self.timeout = timeout self.env = env or {} @@ -383,7 +418,20 @@ def _wrap_command(self, command: str, cwd: str) -> str: quoted_cwd = ( shlex.quote(cwd) if cwd != "~" and not cwd.startswith("~/") else cwd ) - parts.append(f"cd {quoted_cwd} || exit 126") + # Shell-side fallback: if the requested cwd is unreachable (stale path + # Python couldn't detect, e.g. /tmp/... on Windows, or a race where the + # dir disappeared between the Python check and the bash spawn), try the + # startup cwd and emit a stderr breadcrumb. Only exit 126 if both fail. + fallback = self._startup_cwd or "~" + quoted_fallback = ( + shlex.quote(fallback) if fallback != "~" and not fallback.startswith("~/") else fallback + ) + parts.append( + f"cd {quoted_cwd} 2>/dev/null || " + f"{{ cd {quoted_fallback} 2>/dev/null && " + f"echo '[hermes] cwd '{quoted_cwd}' unreachable; using '{quoted_fallback} >&2; }} " + f"|| exit 126" + ) # Run the actual command parts.append(f"eval '{escaped}'") @@ -715,6 +763,18 @@ def execute( effective_timeout = timeout or self.timeout effective_cwd = cwd or self.cwd + # Self-heal stale cwd: if a prior command cd'd into a dir that's since + # been deleted (temp dir, checkpoint, cleaned build artifact), every + # subsequent command would exit 126 from the shell-side cd guard. + # Reset to startup_cwd when we can confirm the path is gone. + if _is_cwd_stale(effective_cwd): + logger.warning( + "Stale cwd %r detected; resetting to startup cwd %r", + effective_cwd, self._startup_cwd, + ) + effective_cwd = self._startup_cwd + self.cwd = effective_cwd + # Merge sudo stdin with caller stdin if sudo_stdin is not None and stdin_data is not None: effective_stdin = sudo_stdin + stdin_data