diff --git a/tests/gateway/test_tui_slash_worker_path.py b/tests/gateway/test_tui_slash_worker_path.py new file mode 100644 index 0000000000..546fa66d4d --- /dev/null +++ b/tests/gateway/test_tui_slash_worker_path.py @@ -0,0 +1,42 @@ +"""Regression test for slash_worker PATH construction (#83845). + +When the gateway is launched by the Desktop/Dashboard app it can inherit a +minimal PATH (/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/sbin:/usr/sbin) +that omits the Hermes venv bin dir and ~/.local/bin. The spawned +tui_gateway.slash_worker then cannot resolve Hermes-managed CLIs such as +browser-use/uvx via shutil.which, breaking browser_exec. + +`tui_gateway.server._prepend_tool_paths` prepends those two directories to +the worker env PATH while preserving the inherited PATH. +""" + +import os +import sys +from pathlib import Path + +from tui_gateway import server as tui_server + + +class TestPrependToolPaths: + def test_prepends_venv_and_user_bin(self): + env = {"PATH": "/usr/bin"} + result = tui_server._prepend_tool_paths(env) + + parts = result["PATH"].split(os.pathsep) + # venv bin first, then user-local bin, then the original PATH preserved + assert parts[0] == str(Path(sys.executable).parent) + assert str(Path.home() / ".local" / "bin") in parts + assert parts[-1] == "/usr/bin" + + def test_preserves_existing_path_when_empty(self): + env = {} + result = tui_server._prepend_tool_paths(env) + + parts = result["PATH"].split(os.pathsep) + assert parts[0] == str(Path(sys.executable).parent) + assert str(Path.home() / ".local" / "bin") in parts + + def test_venv_bin_points_at_sys_executable_dir(self): + env = {"PATH": "/bin"} + result = tui_server._prepend_tool_paths(env) + assert result["PATH"].split(os.pathsep)[0] == str(Path(sys.executable).parent) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b987fda4b1..b6589ec8b4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -368,6 +368,20 @@ _stdio_transport = StdioTransport(lambda: _real_stdout, _stdout_lock) _detached_ws_transport = _DropTransport() +def _prepend_tool_paths(env: dict[str, str]) -> dict[str, str]: + """Prepend the Hermes venv bin dir and the user-local bin dir to PATH + so slash_worker child processes can resolve Hermes-managed CLIs + (browser-use, uvx) even when the parent gateway was launched with a + minimal PATH (e.g. by the Desktop/Dashboard app).""" + venv_bin = str(Path(sys.executable).parent) # /bin (POSIX) or /Scripts (Windows) + user_bin = str(Path.home() / ".local" / "bin") + existing = env.get("PATH") or "" + env["PATH"] = os.pathsep.join( + [p for p in (venv_bin, user_bin) if p] + ([existing] if existing else []) + ) + return env + + class _SlashWorker: """Persistent HermesCLI subprocess for slash commands.""" @@ -404,6 +418,11 @@ class _SlashWorker: inherit_profile_home=False, # base already carries the HOME contract extra={"HERMES_HOME": str(profile_home)} if profile_home else None, ) + # Prepend the Hermes venv bin dir and the user-local bin dir to PATH so + # slash_worker child processes can resolve Hermes-managed CLIs + # (browser-use, uvx) even when the parent gateway was launched with a + # minimal PATH (e.g. by the Desktop/Dashboard app). See #83845. + env = _prepend_tool_paths(env) # start_new_session=True detaches the slash worker into its own # process group / session. Without this, the worker inherits the