Files
hermes-agent/tests/tools/test_browser_homebrew_paths.py
Teknium 1345e93393 fix: add macOS Homebrew paths to browser and terminal PATH resolution
On macOS with Homebrew (Apple Silicon), Node.js and agent-browser
binaries live under /opt/homebrew/bin/ which is not included in the
_SANE_PATH fallback used by browser_tool.py and environments/local.py.
When Hermes runs with a filtered PATH (e.g. as a systemd service),
these binaries are invisible, causing 'env: node: No such file or
directory' errors when using browser tools.

Changes:
- Add /opt/homebrew/bin and /opt/homebrew/sbin to _SANE_PATH in both
  browser_tool.py and environments/local.py
- Add _discover_homebrew_node_dirs() to find versioned Node installs
  (e.g. brew install node@24) that aren't linked into /opt/homebrew/bin
- Extend _find_agent_browser() to search Homebrew and Hermes-managed
  dirs when agent-browser isn't on the current PATH
- Include discovered Homebrew node dirs in subprocess PATH when
  launching agent-browser
- Add 11 new tests covering all Homebrew path discovery logic
2026-03-23 22:45:55 -07:00

260 lines
10 KiB
Python

"""Tests for macOS Homebrew PATH discovery in browser_tool.py."""
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import patch, MagicMock, mock_open
import pytest
from tools.browser_tool import (
_discover_homebrew_node_dirs,
_find_agent_browser,
_run_browser_command,
_SANE_PATH,
)
class TestSanePath:
"""Verify _SANE_PATH includes Homebrew directories."""
def test_includes_homebrew_bin(self):
assert "/opt/homebrew/bin" in _SANE_PATH
def test_includes_homebrew_sbin(self):
assert "/opt/homebrew/sbin" in _SANE_PATH
def test_includes_standard_dirs(self):
assert "/usr/local/bin" in _SANE_PATH
assert "/usr/bin" in _SANE_PATH
assert "/bin" in _SANE_PATH
class TestDiscoverHomebrewNodeDirs:
"""Tests for _discover_homebrew_node_dirs()."""
def test_returns_empty_when_no_homebrew(self):
"""Non-macOS systems without /opt/homebrew/opt should return empty."""
with patch("os.path.isdir", return_value=False):
assert _discover_homebrew_node_dirs() == []
def test_finds_versioned_node_dirs(self):
"""Should discover node@20/bin, node@24/bin etc."""
entries = ["node@20", "node@24", "openssl", "node", "python@3.12"]
def mock_isdir(p):
if p == "/opt/homebrew/opt":
return True
# node@20/bin and node@24/bin exist
if p in (
"/opt/homebrew/opt/node@20/bin",
"/opt/homebrew/opt/node@24/bin",
):
return True
return False
with patch("os.path.isdir", side_effect=mock_isdir), \
patch("os.listdir", return_value=entries):
result = _discover_homebrew_node_dirs()
assert len(result) == 2
assert "/opt/homebrew/opt/node@20/bin" in result
assert "/opt/homebrew/opt/node@24/bin" in result
def test_excludes_plain_node(self):
"""'node' (unversioned) should be excluded — covered by /opt/homebrew/bin."""
with patch("os.path.isdir", return_value=True), \
patch("os.listdir", return_value=["node"]):
result = _discover_homebrew_node_dirs()
assert result == []
def test_handles_oserror_gracefully(self):
"""Should return empty list if listdir raises OSError."""
with patch("os.path.isdir", return_value=True), \
patch("os.listdir", side_effect=OSError("Permission denied")):
assert _discover_homebrew_node_dirs() == []
class TestFindAgentBrowser:
"""Tests for _find_agent_browser() Homebrew path search."""
def test_finds_in_current_path(self):
"""Should return result from shutil.which if available on current PATH."""
with patch("shutil.which", return_value="/usr/local/bin/agent-browser"):
assert _find_agent_browser() == "/usr/local/bin/agent-browser"
def test_finds_in_homebrew_bin(self):
"""Should search Homebrew dirs when not found on current PATH."""
def mock_which(cmd, path=None):
if path and "/opt/homebrew/bin" in path and cmd == "agent-browser":
return "/opt/homebrew/bin/agent-browser"
return None
with patch("shutil.which", side_effect=mock_which), \
patch("os.path.isdir", return_value=True), \
patch(
"tools.browser_tool._discover_homebrew_node_dirs",
return_value=[],
):
result = _find_agent_browser()
assert result == "/opt/homebrew/bin/agent-browser"
def test_finds_npx_in_homebrew(self):
"""Should find npx in Homebrew paths as a fallback."""
def mock_which(cmd, path=None):
if cmd == "agent-browser":
return None
if cmd == "npx":
if path and "/opt/homebrew/bin" in path:
return "/opt/homebrew/bin/npx"
return None
return None
# Mock Path.exists() to prevent the local node_modules check from matching
original_path_exists = Path.exists
def mock_path_exists(self):
if "node_modules" in str(self) and "agent-browser" in str(self):
return False
return original_path_exists(self)
with patch("shutil.which", side_effect=mock_which), \
patch("os.path.isdir", return_value=True), \
patch.object(Path, "exists", mock_path_exists), \
patch(
"tools.browser_tool._discover_homebrew_node_dirs",
return_value=[],
):
result = _find_agent_browser()
assert result == "npx agent-browser"
def test_raises_when_not_found(self):
"""Should raise FileNotFoundError when nothing works."""
original_path_exists = Path.exists
def mock_path_exists(self):
if "node_modules" in str(self) and "agent-browser" in str(self):
return False
return original_path_exists(self)
with patch("shutil.which", return_value=None), \
patch("os.path.isdir", return_value=False), \
patch.object(Path, "exists", mock_path_exists), \
patch(
"tools.browser_tool._discover_homebrew_node_dirs",
return_value=[],
):
with pytest.raises(FileNotFoundError, match="agent-browser CLI not found"):
_find_agent_browser()
class TestRunBrowserCommandPathConstruction:
"""Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH."""
def test_subprocess_path_includes_homebrew_node_dirs(self, tmp_path):
"""When _discover_homebrew_node_dirs returns dirs, they should appear
in the subprocess env PATH passed to Popen."""
captured_env = {}
# Create a mock Popen that captures the env dict
mock_proc = MagicMock()
mock_proc.returncode = 0
mock_proc.wait.return_value = 0
def capture_popen(cmd, **kwargs):
captured_env.update(kwargs.get("env", {}))
return mock_proc
fake_session = {
"session_name": "test-session",
"session_id": "test-id",
"cdp_url": None,
}
# Write fake JSON output to the stdout temp file
fake_json = json.dumps({"success": True})
stdout_file = tmp_path / "stdout"
stdout_file.write_text(fake_json)
fake_homebrew_dirs = [
"/opt/homebrew/opt/node@24/bin",
"/opt/homebrew/opt/node@20/bin",
]
# We need os.path.isdir to return True for our fake dirs
# but we also need real isdir for tmp_path operations
real_isdir = os.path.isdir
def selective_isdir(p):
if p in fake_homebrew_dirs or p.startswith(str(tmp_path)):
return True
if "/opt/homebrew/" in p:
return True # _SANE_PATH dirs
return real_isdir(p)
with patch("tools.browser_tool._find_agent_browser", return_value="/usr/local/bin/agent-browser"), \
patch("tools.browser_tool._get_session_info", return_value=fake_session), \
patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \
patch("tools.browser_tool._discover_homebrew_node_dirs", return_value=fake_homebrew_dirs), \
patch("os.path.isdir", side_effect=selective_isdir), \
patch("subprocess.Popen", side_effect=capture_popen), \
patch("os.open", return_value=99), \
patch("os.close"), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch.dict(os.environ, {"PATH": "/usr/bin:/bin", "HOME": "/home/test"}, clear=True):
# The function reads from temp files for stdout/stderr
with patch("builtins.open", mock_open(read_data=fake_json)):
_run_browser_command("test-task", "navigate", ["https://example.com"])
# Verify Homebrew node dirs made it into the subprocess PATH
result_path = captured_env.get("PATH", "")
assert "/opt/homebrew/opt/node@24/bin" in result_path
assert "/opt/homebrew/opt/node@20/bin" in result_path
assert "/opt/homebrew/bin" in result_path # from _SANE_PATH
def test_subprocess_path_includes_sane_path_homebrew(self, tmp_path):
"""_SANE_PATH Homebrew entries should appear even without versioned node dirs."""
captured_env = {}
mock_proc = MagicMock()
mock_proc.returncode = 0
mock_proc.wait.return_value = 0
def capture_popen(cmd, **kwargs):
captured_env.update(kwargs.get("env", {}))
return mock_proc
fake_session = {
"session_name": "test-session",
"session_id": "test-id",
"cdp_url": None,
}
fake_json = json.dumps({"success": True})
real_isdir = os.path.isdir
def selective_isdir(p):
if "/opt/homebrew/" in p:
return True
if p.startswith(str(tmp_path)):
return True
return real_isdir(p)
with patch("tools.browser_tool._find_agent_browser", return_value="/usr/local/bin/agent-browser"), \
patch("tools.browser_tool._get_session_info", return_value=fake_session), \
patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \
patch("tools.browser_tool._discover_homebrew_node_dirs", return_value=[]), \
patch("os.path.isdir", side_effect=selective_isdir), \
patch("subprocess.Popen", side_effect=capture_popen), \
patch("os.open", return_value=99), \
patch("os.close"), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch.dict(os.environ, {"PATH": "/usr/bin:/bin", "HOME": "/home/test"}, clear=True):
with patch("builtins.open", mock_open(read_data=fake_json)):
_run_browser_command("test-task", "navigate", ["https://example.com"])
result_path = captured_env.get("PATH", "")
assert "/opt/homebrew/bin" in result_path
assert "/opt/homebrew/sbin" in result_path