{"repo":"NousResearch/hermes-agent","public_id":"15989","display_label":"TUI crashes when cwd contains shadowing Python packages","summary":"These issues and fixes address a bug where the TUI or its gateway child process fails to launch when the current working directory contains a Python package (e.g., 'utils', 'proxy', 'ui') that shadows internal modules via sys.path. The fixes guard sys.path in the slash_worker and tui_gateway to prevent local packages from interfering with internal imports.","subsystem":"gateway","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19885},"resolution":{"state":"resolved","by":"pr-19885,pr-51693"},"freshness":{"state":"verified","last_verified_at":"2026-07-01T12:29:23+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":15989,"pr_number":19885,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":51286,"pr_number":51693,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":51286,"pr_number":56379,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":15989,"title":"TUI fails to launch when cwd contains a Python package named 'utils/'","url":null,"state":"closed"},{"kind":"issue","number":51286,"title":"[Bug]: Gateway child crashes (exit code 1) when cwd contains Python package named `utils`","url":null,"state":"closed"},{"kind":"pr","number":16141,"title":"fix(tui_gateway): guard sys.path against local package shadowing (#15989)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19885","number":19885}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19885,"title":"fix(tui_gateway): guard sys.path against local package shadowing","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_entry_sys_path.py","tui_gateway/entry.py"],"fix_hunks":[{"file":"tests/tui_gateway/test_entry_sys_path.py","hunks":["@@ -0,0 +1,101 @@\n+\"\"\"Tests for tui_gateway/entry.py sys.path hardening (issue #15989).\n+\n+When the TUI backend is spawned by Node.js, the Python interpreter may have\n+'' or '.' at the front of sys.path, allowing a local utils/ directory in CWD\n+to shadow the installed utils module.  entry.py must sanitize sys.path before\n+any non-stdlib import is resolved.\n+\"\"\"\n+\n+import importlib\n+import os\n+import sys\n+from unittest.mock import patch\n+\n+\n+def _reload_entry_with_env(env_overrides: dict) -> None:\n+    \"\"\"Re-execute entry.py's module-level path setup under a controlled env.\"\"\"\n+    # We only want to exercise the sys.path fixup block, not the signal/import\n+    # machinery that follows.  We do this by running the fixup code verbatim in\n+    # a fresh copy of sys.path rather than importing the real module (which\n+    # would trigger tui_gateway.server imports requiring heavy mocks).\n+    original_path = sys.path[:]\n+    original_env = {k: os.environ.get(k) for k in env_overrides}\n+    try:\n+        with patch.dict(os.environ, env_overrides, clear=False):\n+            _src_root = os.environ.get(\"HERMES_PYTHON_SRC_ROOT\", \"\")\n+            if _src_root and _src_root not in sys.path:\n+                sys.path.insert(0, _src_root)\n+            sys.path = [p for p in sys.path if p not in (\"\", \".\")]\n+        return sys.path[:]\n+    finally:\n+        sys.path = original_path\n+        for k, v in original_env.items():\n+            if v is None:\n+                os.environ.pop(k, None)\n+            else:\n+                os.environ[k] = v\n+\n+\n+def test_empty_string_and_dot_removed_from_sys_path():\n+    original = sys.path[:]\n+    try:\n+        sys.path.insert(0, \"\")\n+        sys.path.insert(0, \".\")\n+        assert \"\" in sys.path\n+        assert \".\" in sys.path\n+\n+        # Run the entry.py fixup logic directly\n+        sys.path = [p for p in sys.path if p not in (\"\", \".\")]\n+\n+        assert \"\" not in sys.path\n+        assert \".\" not in sys.path\n+    finally:\n+        sys.path = original\n+\n+\n+def test_hermes_src_root_inserted_at_front():\n+    original = sys.path[:]\n+    try:\n+        fake_root = \"/fake/hermes/src\"\n+        with patch.dict(os.environ, {\"HERMES_PYTHON_SRC_ROOT\": fake_root}):\n+            _src_root = os.environ.get(\"HERMES_PYTHON_SRC_ROOT\", \"\")\n+            if _src_root and _src_root not in sys.path:\n+                sys.path.insert(0, _src_root)\n+            sys.path = [p for p in sys.path if p not in (\"\", \".\")]\n+\n+        assert sys.path[0] == fake_root\n+    finally:\n+        sys.path = original\n+\n+\n+def test_src_root_not_duplicated_if_already_present():\n+    original = sys.path[:]\n+    try:\n+        fake_root = \"/already/present\"\n+        sys.path.insert(0, fake_root)\n+        count_before = sys.path.count(fake_root)\n+\n+        with patch.dict(os.environ, {\"HERMES_PYTHON_SRC_ROOT\": fake_root}):\n+            _src_root = os.environ.get(\"HERMES_PYTHON_SRC_ROOT\", \"\")\n+            if _src_root and _src_root not in sys.path:\n+                sys.path.insert(0, _src_root)\n+            sys.path = [p for p in sys.path if p not in (\"\", \".\")]\n+\n+        assert sys.path.count(fake_root) == count_before\n+    finally:\n+        sys.path = original\n+\n+\n+def test_no_src_root_env_does_not_crash():\n+    original = sys.path[:]\n+    try:\n+        env = {k: v for k, v in os.environ.items() if k != \"HERMES_PYTHON_SRC_ROOT\"}\n+        with patch.dict(os.environ, {}, clear=True):\n+            os.environ.update(env)\n+            _src_root = os.environ.get(\"HERMES_PYTHON_SRC_ROOT\", \"\")\n+            if _src_root and _src_root not in sys.path:\n+                sys.path.insert(0, _src_root)\n+            sys.path = [p for p in sys.path if p not in (\"\", \".\")]\n+        # No exception raised\n+    finally:\n+        sys.path = original"]},{"file":"tui_gateway/entry.py","hunks":["@@ -1,7 +1,18 @@\n-import json\n import os\n-import signal\n import sys\n+\n+# Guard against a local utils/ (or other package) in CWD shadowing installed\n+# hermes modules.  hermes_cli sets HERMES_PYTHON_SRC_ROOT before spawning this\n+# subprocess; inserting it first ensures the installed packages win.\n+_src_root = os.environ.get(\"HERMES_PYTHON_SRC_ROOT\", \"\")\n+if _src_root and _src_root not in sys.path:\n+    sys.path.insert(0, _src_root)\n+# Strip '' and '.' — both resolve to CWD at import time and can let a local\n+# directory shadow installed packages.\n+sys.path = [p for p in sys.path if p not in (\"\", \".\")]\n+\n+import json\n+import signal\n import time\n import traceback\n \n"]}]},{"kind":"pr","number":51304,"title":"fix(tui_gateway): prevent cwd packages from shadowing internal modules","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c39b2b50eeb8c338e147739e6fe3a50e2be86192","number":0},{"kind":"pr","value":"#51286","number":51286},{"kind":"pr","value":"#51693","number":51693}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51484,"title":"fix(tui): guard slash_worker sys.path against local package shadowing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56379","number":56379},{"kind":"pr","value":"#51693","number":51693}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":51693,"title":"fix(tui): stop a cwd package named utils/proxy/ui from crashing the gateway child","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/entry.py","hermes_bootstrap.py","tests/test_hermes_bootstrap.py","tests/tui_gateway/test_entry_sys_path.py","tui_gateway/entry.py","ui-tui/src/gatewayClient.ts"]},{"kind":"pr","number":56379,"title":"fix(tui): guard slash_worker sys.path against local package shadowing (#51286)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_slash_worker_sys_path.py","tui_gateway/slash_worker.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}