Skip to content

fix(tui_gateway): tolerate a deleted working directory across session, completion and exec paths (supersedes #40153) - #76131

Open
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/tui-gateway-safe-getcwd-40153
Open

fix(tui_gateway): tolerate a deleted working directory across session, completion and exec paths (supersedes #40153)#76131
briandevans wants to merge 2 commits into
NousResearch:mainfrom
briandevans:fix/tui-gateway-safe-getcwd-40153

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

The guard for this exact bug is already enforced in tools/ and entirely absent in tui_gateway/. tools/terminal_tool.py::_safe_getcwd (:1314) landed as merged ad69d3edc / #39491 "fix(terminal): guard os.getcwd() against a deleted CWD"; #19928 "fix(local): recover when persistent_shell cwd is deleted (#17558)" and #19933 merged the same idea on the local-shell surface. git grep _safe_getcwd -- tui_gateway/ on main returns nothing — the TUI gateway never got it. This PR closes that asymmetry.

In user terms: "I deleted (or git worktree remove'd, or rebuilt) the folder I launched Hermes in, and now the TUI can't open a new chat, can't resume a session, and slash commands are dead — the gateway just falls over."

os.getcwd() raises FileNotFoundError once the process's working directory is removed out from under it. tui_gateway/ calls it unguarded on seven fallback paths.

This is process death, not degradation. session.create and session.resume are not in server._LONG_HANDLERS, so dispatch() runs them inline via return handle_request(req) with no except, and tui_gateway/entry.py:480 calls resp = dispatch(req) with no try/except and none in main() — an escaping FileNotFoundError exits the stdio gateway process. Only the Desktop/WS path degrades, because tui_gateway/ws.py:383 catches around its own dispatch call and returns -32603.

Reachability chains, verified on main:

RPC resolves through inline? outcome today
session.create methods_session.py:34_completion_cwd inline gateway process exits
session.resume methods_session.py:423/:512_default_session_cwd inline gateway process exits
shell.exec methods_tools.py:1889 inline -32000 / 5003, command never runs
config.show methods_tools.py:1411 inline 5030, settings panel fails to render
cli.exec methods_tools.py:394 pool -32000 handler error

_completion_cwd's isdir-failed tail is reachable even when the client supplies a cwd — in this scenario the client's last-known cwd is the deleted directory, so os.path.isdir() returns False and the fallback fires. The except Exception: pass above it does not cover that return.

Related Issue

No issue exists for this surface, so there is no Fixes: line. The nearest filed report, #62169 ("Terminal sandbox: deleted CWD permanently breaks all subsequent commands (exit 126)"), is the terminal-sandbox surface and is already fixed there — cited as corroborating evidence that users hit this class in the wild, not as the issue this closes.

Supersedes #40153 (@Dusk1e). See below.

Supersedes #40153 — deficiency shown mechanically, not argued

#40153 identified a real bug and its helper is sound. It has been CONFLICTING / DIRTY since 2026-06-05 (last commit 2026-06-05T22:26:10Z; its only force-push predates the 07-14 review by five weeks) and is incomplete against current main. Two mechanical facts, not opinions:

  1. grep -c '_default_session_cwd' over its entire diff returns 0. It never touches the site the review explicitly named.
  2. Its hunk anchors are 208 / 234 / 793 / 801; main's real call sites are 381 / 1371 / 2179 / 2187 — roughly 1400 lines of drift. That drift is why it reads DIRTY, and the _SlashWorker block it patches has since gained profile-home env scoping ([Bug]: Profile-local skills are unavailable in Dashboard/TUI/Desktop GUI because child processes use the root HERMES_HOME #40677), UTF-8 lossy decode (fix: TUI gateway crash on Windows - UnicodeDecodeError in subprocess _readerthread #53137), windows_hide_flags() and start_new_session=True. Its diff still shows the old env=os.environ.copy() form with no encoding=/creationflags=/start_new_session=.

The 2026-07-14 automated review on #40153 (keep_open salvageability=high) states both points:

### Problems

  • Current main also has a later-added unguarded fallback in _default_session_cwd() (tui_gateway/server.py:1116). A deleted launch CWD can still fail that default/resume route after this PR's three substitutions.

### Suggested changes

  • During salvage, apply the safe resolver to _default_session_cwd() and add coverage for that path alongside the slash-worker and completion cases.
  • Preserve the current _SlashWorker subprocess setup at tui_gateway/server.py:295-324, including profile-home environment scoping and platform process flags.

Both are satisfied here: _default_session_cwd is substituted and covered by a dedicated test, and the _SlashWorker block is preserved byte-for-byte apart from the cwd= argument — with env, creationflags, start_new_session=True, encoding="utf-8" and errors="replace" asserted in a test rather than merely promised.

Credit for identifying the failure path belongs to @Dusk1e; this is the complete, rebased version against current main.

Three-way red-before proof

Run against the new test file, which is identical in all three legs. This is the artifact that distinguishes a genuine improvement from a re-file:

leg tui_gateway state result
1 clean origin/main, no fix 14 failed, 0 passed — all red
2 cut back to #40153's exact 3-site shape (helper + _SlashWorker + both _completion_cwd sites) 4 failed, 10 passed_default_session_cwd and all three methods_tools cases still red
3 this PR 14 passed

Leg 2's four survivors are exactly test_default_session_cwd_survives_deleted_cwd, test_cli_exec_runs_with_fallback_cwd, test_shell_exec_runs_with_fallback_cwd, test_config_show_reports_fallback_working_dir.

Changes Made

  • tui_gateway/server.py — added _safe_getcwd() immediately above _default_session_cwd, mirroring tools/terminal_tool.py:1314 exactly (same TERMINAL_CWDexpanduser("~") chain) so the two surfaces cannot drift. Substituted at four sites: _SlashWorker spawn (:381), _default_session_cwd (:1371), _completion_cwd or-chain (:2179), _completion_cwd isdir-failed tail (:2187).
  • tui_gateway/methods_tools.py — substituted at three more sites sharing the identical root cause: cli.exec (:394), config.show (:1411), shell.exec (:1889).
  • tests/tui_gateway/test_safe_getcwd.py — new file, 14 tests.

Exception type — FileNotFoundError, not OSError. Chosen for exact parity with the merged tools/terminal_tool.py idiom, so a reader diffing the two helpers sees them as identical. A genuine PermissionError therefore still surfaces instead of being silently masked, and a test pins that. #67308 is separately proposing to widen the tools/ helper to cover the macOS-TCC PermissionError case; if that merges, widening this copy to match is a one-line follow-up. Diverging pre-emptively would create exactly the drift this PR exists to remove.

No import added to methods_tools.py — this is deliberate. tui_gateway/method_ctx.py::HandlerRegistry.install() rebuilds every handler with types.FunctionType(fn.__code__, vars(server), ...), so names in a methods_*.py handler body resolve against server.py's namespace — which is why methods_complete.py calls bare _completion_cwd while importing only HandlerRegistry. All three sites were individually confirmed to sit inside @method bodies (cli.exec :371, config.show :1383, shell.exec :1866), and a parametrized test asserts _safe_getcwd is present in each rebound handler's __globals__ so these sites cannot ship a latent NameError.

Sites deliberately excluded

tui_gateway/compute_host.py:520 (str(frame.get("cwd") or os.getcwd())) and :752 (the hello handshake) are not substituted. Both rest on one verifiable fact rather than two separate arguments: tui_gateway/host_supervisor.py:326 spawns that child with an explicit cwd=str(self.cwd), and self.cwd defaults to _repo_root() (:150), never the user's launch directory. The compute-host process therefore always runs with a live, installation-owned cwd, so os.getcwd() there cannot observe the deleted launch dir — and if that directory were itself missing, Popen would fail at spawn time and the child would never reach either line. Substituting there would add unreachable code.

How to Test

uv run --with pytest --with pytest-asyncio python3 -m pytest tests/tui_gateway/test_safe_getcwd.py -v

To reproduce the original failure by hand:

  1. mkdir /tmp/scratch && cd /tmp/scratch, launch the TUI from there.
  2. From another shell, rmdir /tmp/scratch.
  3. In the TUI, start a new chat (session.create) or resume one (session.resume). On main the stdio gateway process exits; with this PR the session opens in TERMINAL_CWD, falling back to the user's home directory.

Per-file results (CI uses per-file subprocess isolation, so these were verified per file, not via a flat pytest tests/ run):

file result
tests/tui_gateway/test_safe_getcwd.py 14 passed
tests/tui_gateway/test_protocol.py 37 passed
tests/tui_gateway/test_session_cwd_follow.py 7 passed
tests/tui_gateway/test_subprocess_encoding.py 4 passed
tests/tui_gateway/test_slash_worker_profile_home.py 1 passed
tests/tui_gateway/test_slash_worker_sys_path.py 3 passed
tests/tui_gateway/test_slash_worker_ansi.py 1 passed
tests/tui_gateway/test_slash_worker_mcp_discovery.py 1 passed
tests/gateway/test_complete_path_at_filter.py 9 passed
tests/gateway/test_completion_delivery.py 7 passed
tests/gateway/test_config_cwd_bridge.py 12 passed
tests/gateway/test_cwd_placeholder.py 2 passed

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — ran the affected files individually instead (table above); a flat pytest tests/ run is not reproducible in this repo, and CI isolates per file
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.11.14

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — docstrings on the new helper and the _completion_cwd tail; no user-facing docs affected
  • N/A — no config keys added or changed
  • N/A — no architecture or workflow change
  • I've considered cross-platform impact (Windows, macOS) — the helper is platform-neutral (os.getenv + expanduser); no os.name branching was introduced, and the Windows-specific creationflags/windows_hide_flags() on both subprocess blocks are preserved and asserted. Executed on macOS only.
  • N/A — no tool descriptions or schemas changed

…, completion and exec paths

os.getcwd() raises FileNotFoundError once the process's working directory is
removed out from under it — the folder Hermes was launched in gets deleted,
rebuilt, or `git worktree remove`'d mid-session. tui_gateway called it
unguarded on seven fallback paths.

This is process death, not degradation. session.create and session.resume are
not in server._LONG_HANDLERS, so they run inline on the reader thread, and
tui_gateway/entry.py calls dispatch(req) with no try/except — an escaping
FileNotFoundError exits the stdio gateway. Only the Desktop/WS path degrades,
because ws.py catches around its dispatch call.

Add _safe_getcwd() to tui_gateway/server.py, mirroring the already-merged
tools/terminal_tool.py helper (NousResearch#39491 / ad69d3e) exactly, including its
TERMINAL_CWD -> home fallback chain, so the two surfaces cannot drift. Catch
FileNotFoundError only, for parity: a genuine PermissionError still surfaces
rather than being masked.

Substitute at all seven sites:
  server.py        _SlashWorker spawn, _default_session_cwd,
                   _completion_cwd or-chain, _completion_cwd isdir-failed tail
  methods_tools.py cli.exec, config.show, shell.exec

_completion_cwd's tail is reachable even when the client supplies a cwd: in
this scenario that cwd IS the deleted directory, so os.path.isdir returns
False and the fallback fires. The `except Exception: pass` above it does not
cover that return.

methods_tools.py calls the bare name with no import. HandlerRegistry.install()
rebuilds every handler with types.FunctionType(fn.__code__, vars(server), ...),
so handler bodies resolve against server.py's namespace — the same reason
methods_complete.py calls bare _completion_cwd. A test pins that the name is
resolvable in each rebound handler's __globals__ so these sites cannot ship a
latent NameError.

The _SlashWorker subprocess block is otherwise untouched: profile-home env
scoping (NousResearch#40677), UTF-8 lossy decode (NousResearch#53137), windows_hide_flags() and
start_new_session=True are all preserved, and asserted in the new test.

tui_gateway/compute_host.py's two os.getcwd() calls are deliberately excluded:
host_supervisor.py spawns that child with an explicit cwd=_repo_root(), so the
host process's cwd is a live installation-owned directory by construction and
cannot be the deleted launch dir.
Copilot AI review requested due to automatic review settings August 1, 2026 11:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Ready to approve

The change is narrowly scoped, fixes a concrete crash condition, and includes direct regression coverage for the affected call paths.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Fixes a TUI gateway crash class where os.getcwd() raises FileNotFoundError after the process working directory is deleted mid-session, by centralizing a safe cwd resolver in tui_gateway and routing all relevant fallback paths through it.

Changes:

  • Add tui_gateway.server._safe_getcwd() and use it for _SlashWorker, _default_session_cwd(), and both _completion_cwd() fallback paths.
  • Replace os.getcwd() with _safe_getcwd() in tui_gateway/methods_tools.py for cli.exec, config.show, and shell.exec.
  • Add a focused regression test suite covering helper behavior and the substituted call sites.
File summaries
File Description
tui_gateway/server.py Introduces _safe_getcwd() and routes session/completion/slash-worker cwd fallbacks through it to prevent stdio gateway process exits.
tui_gateway/methods_tools.py Uses _safe_getcwd() for tool subprocess cwd and config display to avoid crashes when the launch CWD is deleted.
tests/tui_gateway/test_safe_getcwd.py Adds regression coverage for deleted-CWD behavior across helper, session defaults, completion, slash worker, and handler rebinding.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread tui_gateway/server.py Outdated
Comment on lines +1369 to +1377
Every ``os.getcwd()`` in this package is a *fallback* on a path that has
already exhausted its explicit sources, so a raise here is never recoverable
locally — ``session.create``/``session.resume`` run inline (they are not in
``_LONG_HANDLERS``) and ``tui_gateway/entry.py`` does not guard
``dispatch()``, so the stdio gateway process exits.

Mirrors :func:`tools.terminal_tool._safe_getcwd` (added in #39491) exactly,
including the TERMINAL_CWD -> home fallback chain, so the two surfaces
cannot drift.
Comment on lines +109 to +132
def test_slash_worker_spawns_with_fallback_cwd_and_preserves_contract(monkeypatch, tmp_path):
"""The cwd substitution must not disturb the profile-home env scoping
(#40677), UTF-8 lossy decode (#53137), windows_hide_flags() or
start_new_session=True that this block has accumulated."""
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
with patch.dict("sys.modules", {
"hermes_constants": MagicMock(
get_hermes_home=MagicMock(return_value=str(tmp_path))
),
}):
with patch("subprocess.Popen") as mock_popen:
mock_popen.return_value.stdout = MagicMock()
mock_popen.return_value.stderr = MagicMock()
with patch("os.getcwd", _getcwd_raises()):
server._SlashWorker(session_key="k", model="m")

assert mock_popen.called, "Popen was not invoked"
kwargs = mock_popen.call_args[1]
assert kwargs["cwd"] == str(tmp_path)
# preservation guarantee, asserted rather than promised
assert kwargs["env"] is not None
assert kwargs["start_new_session"] is True
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tui Terminal UI (ui-tui/ + tui_gateway/) sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 1, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused deleted-CWD recovery coverage. The premise is confirmed on current main: tui_gateway/server.py:2179 and :2187 retain unguarded completion fallbacks, _default_session_cwd() calls os.getcwd() at :1371, and the three methods_tools.py sites are likewise unguarded. The proposed helper matches the merged terminal precedent at tools/terminal_tool.py:1314.

Problems

  • The new helper and test-module docstrings say session.resume is not in _LONG_HANDLERS and runs inline. Current main lists session.resume at tui_gateway/server.py:276 and shell.exec at :277; these paths are caught by the pool wrapper at :1793-1798. The safe fallback remains correct, but the claimed process-death mechanism is only accurate for inline paths such as session.create.

Suggested changes

  • Narrow those comments to the actual inline path and scope the “all sites” language to the seven selected fallback sites. tui_gateway/compute_host.py:520 and :752 remain direct os.getcwd() calls, with the normal supervisor construction using an explicit repository-root cwd (tui_gateway/host_supervisor.py:326-329).

Automated hermes-sweeper review.

Comment thread tui_gateway/server.py Outdated

Mirrors :func:`tools.terminal_tool._safe_getcwd` (added in #39491) exactly,
including the TERMINAL_CWD -> home fallback chain, so the two surfaces
cannot drift.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please scope this claim to the seven selected fallback sites. tui_gateway/compute_host.py:520 and :752 still call os.getcwd() directly (the normal supervisor starts that child with an explicit repository-root cwd), so “every os.getcwd() in this package” and “cannot drift” overstate the guarantee.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — both claims were too broad. Scoped in c6d5a4a.

The docstring now names the seven sites this helper actually guards, instead of claiming every os.getcwd() in the package: _SlashWorker, _default_session_cwd and both _completion_cwd returns in server.py, plus cli.exec, config.show and shell.exec in methods_tools.py.

It also records the compute_host.py:520 / :752 exclusion and the reason, as you describe it: host_supervisor.py:328 starts that child with cwd=str(self.cwd), which defaults to _repo_root() (host_supervisor.py:150), so the compute host never inherits the launch directory and cannot observe it deleted — Popen would fail before the child ran if it could. Those two stay bare os.getcwd() on that basis rather than widening the diff.

"cannot drift" is gone. The two bodies are byte-identical today, but they are independent copies with nothing enforcing the mirror, so the docstring now says to change both together.

The same commit also tightens the _SlashWorker test, which claimed to cover profile-home env scoping (#40677) but passed no profile_home= and only asserted env is not None. It now passes a profile home distinct from the expected fallback cwd and asserts HERMES_HOME in the spawned env; the assertion fails if the build_subprocess_env extra= override is dropped.

@teknium1 teknium1 added sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history labels Aug 1, 2026
…ack sites

The docstring claimed every os.getcwd() in tui_gateway is a fallback and that
this helper "cannot drift" from tools.terminal_tool._safe_getcwd. Both overstate
the guarantee.

compute_host.py:520 and :752 still call os.getcwd() directly, and that is
correct: host_supervisor.py spawns the compute host with cwd=str(self.cwd)
(:328), which defaults to _repo_root() (:150), so that child cannot observe a
deleted launch directory — Popen would fail first if it could. Name the seven
sites this helper actually guards and record the exclusion with its reason.

The two helpers are byte-identical today but are independent copies with
nothing enforcing the mirror, so say "change both together" instead of
claiming they cannot drift.

Also make the _SlashWorker test assert the contract its docstring promises: it
claimed to cover profile-home env scoping (NousResearch#40677) while passing no
profile_home= and only asserting env is not None. It now passes a profile home
distinct from the expected fallback cwd and asserts HERMES_HOME in the spawned
env, which fails if the build_subprocess_env extra= override is removed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants