From 60b143e9dfca5f90e2ecb09391a7f1832ee592e1 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:32:41 -0700 Subject: [PATCH] fix(tui_gateway): guard sys.path against local package shadowing (#15989) When the TUI backend (tui_gateway/entry.py) is spawned by Node.js with the user's CWD containing a local utils/ directory, that directory shadows the installed utils module, causing ImportError in run_agent and hermes_cli. Strip '' and '.' from sys.path and prepend HERMES_PYTHON_SRC_ROOT (already set by hermes_cli before spawning the subprocess) so installed packages always win over CWD artifacts. Co-Authored-By: Claude Sonnet 4.6 --- tests/tui_gateway/test_entry_sys_path.py | 101 +++++++++++++++++++++++ tui_gateway/entry.py | 15 +++- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 tests/tui_gateway/test_entry_sys_path.py diff --git a/tests/tui_gateway/test_entry_sys_path.py b/tests/tui_gateway/test_entry_sys_path.py new file mode 100644 index 0000000000..f8741b18e4 --- /dev/null +++ b/tests/tui_gateway/test_entry_sys_path.py @@ -0,0 +1,101 @@ +"""Tests for tui_gateway/entry.py sys.path hardening (issue #15989). + +When the TUI backend is spawned by Node.js, the Python interpreter may have +'' or '.' at the front of sys.path, allowing a local utils/ directory in CWD +to shadow the installed utils module. entry.py must sanitize sys.path before +any non-stdlib import is resolved. +""" + +import importlib +import os +import sys +from unittest.mock import patch + + +def _reload_entry_with_env(env_overrides: dict) -> None: + """Re-execute entry.py's module-level path setup under a controlled env.""" + # We only want to exercise the sys.path fixup block, not the signal/import + # machinery that follows. We do this by running the fixup code verbatim in + # a fresh copy of sys.path rather than importing the real module (which + # would trigger tui_gateway.server imports requiring heavy mocks). + original_path = sys.path[:] + original_env = {k: os.environ.get(k) for k in env_overrides} + try: + with patch.dict(os.environ, env_overrides, clear=False): + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + return sys.path[:] + finally: + sys.path = original_path + for k, v in original_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def test_empty_string_and_dot_removed_from_sys_path(): + original = sys.path[:] + try: + sys.path.insert(0, "") + sys.path.insert(0, ".") + assert "" in sys.path + assert "." in sys.path + + # Run the entry.py fixup logic directly + sys.path = [p for p in sys.path if p not in ("", ".")] + + assert "" not in sys.path + assert "." not in sys.path + finally: + sys.path = original + + +def test_hermes_src_root_inserted_at_front(): + original = sys.path[:] + try: + fake_root = "/fake/hermes/src" + with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}): + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + + assert sys.path[0] == fake_root + finally: + sys.path = original + + +def test_src_root_not_duplicated_if_already_present(): + original = sys.path[:] + try: + fake_root = "/already/present" + sys.path.insert(0, fake_root) + count_before = sys.path.count(fake_root) + + with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}): + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + + assert sys.path.count(fake_root) == count_before + finally: + sys.path = original + + +def test_no_src_root_env_does_not_crash(): + original = sys.path[:] + try: + env = {k: v for k, v in os.environ.items() if k != "HERMES_PYTHON_SRC_ROOT"} + with patch.dict(os.environ, {}, clear=True): + os.environ.update(env) + _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") + if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) + sys.path = [p for p in sys.path if p not in ("", ".")] + # No exception raised + finally: + sys.path = original diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index d3be53a6c4..0fe87ca49c 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -1,7 +1,18 @@ -import json import os -import signal import sys + +# Guard against a local utils/ (or other package) in CWD shadowing installed +# hermes modules. hermes_cli sets HERMES_PYTHON_SRC_ROOT before spawning this +# subprocess; inserting it first ensures the installed packages win. +_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") +if _src_root and _src_root not in sys.path: + sys.path.insert(0, _src_root) +# Strip '' and '.' — both resolve to CWD at import time and can let a local +# directory shadow installed packages. +sys.path = [p for p in sys.path if p not in ("", ".")] + +import json +import signal import time import traceback