mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
fix(browser): validate agent-browser is runnable, not just present (#51740)
After `hermes update`, a globally-installed agent-browser's npm postinstall (fixUnixSymlink) re-points the global symlink (e.g. /opt/homebrew/bin/agent-browser) at our local node_modules binary. The next update wipes node_modules, leaving a dangling symlink that `which` still reports but exec fails on with exit 127 — silently breaking every browser tool (#48521). Root cause is trust-on-presence: shutil.which/Path.exists accept a name that resolves but won't run. Add hermes_constants.agent_browser_runnable() (resolves the path + runs --version) and gate all four resolution sites on it: _find_agent_browser now skips a dead candidate and falls through to the next working one (extended PATH -> local .bin -> npx), self-healing the dangling link. dep_ensure/doctor/nous_subscription validate too; doctor warns on a broken link. Closes #48521.
This commit is contained in:
parent
a911bcda18
commit
3c75e11571
8 changed files with 143 additions and 26 deletions
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
import hermes_constants
|
||||
from hermes_constants import (
|
||||
VALID_REASONING_EFFORTS,
|
||||
agent_browser_runnable,
|
||||
find_hermes_node_executable,
|
||||
find_node_executable,
|
||||
find_node_executable_on_path,
|
||||
|
|
@ -424,3 +425,48 @@ class TestSecureParentDir:
|
|||
secure_parent_dir(link_target)
|
||||
assert len(called_with) == 1
|
||||
assert called_with[0] == (str(real_dir), 0o700)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX shell stubs; Windows uses .cmd shims")
|
||||
class TestAgentBrowserRunnable:
|
||||
"""agent_browser_runnable() validates the resolved CLI actually runs.
|
||||
|
||||
Regression coverage for issue #48521: a dangling global symlink left by
|
||||
agent-browser's npm postinstall is reported by ``which`` but fails at exec
|
||||
with exit 127, silently breaking every browser tool. The validator must
|
||||
reject it (and other non-runnable candidates) so callers fall through.
|
||||
"""
|
||||
|
||||
def _stub(self, tmp_path, name, body, mode=0o755):
|
||||
p = tmp_path / name
|
||||
p.write_text(body)
|
||||
p.chmod(mode)
|
||||
return p
|
||||
|
||||
def test_none_and_empty_rejected(self):
|
||||
assert agent_browser_runnable(None) is False
|
||||
assert agent_browser_runnable("") is False
|
||||
|
||||
def test_dangling_symlink_rejected(self, tmp_path):
|
||||
link = tmp_path / "agent-browser"
|
||||
link.symlink_to(tmp_path / "does-not-exist")
|
||||
# exists() follows the link → False, so it's rejected without exec.
|
||||
assert agent_browser_runnable(str(link)) is False
|
||||
|
||||
def test_runnable_binary_accepted(self, tmp_path):
|
||||
good = self._stub(tmp_path, "agent-browser", "#!/bin/sh\necho 'agent-browser 0.27.1'\nexit 0\n")
|
||||
assert agent_browser_runnable(str(good)) is True
|
||||
|
||||
def test_nonzero_exit_rejected(self, tmp_path):
|
||||
bad = self._stub(tmp_path, "agent-browser", "#!/bin/sh\nexit 127\n")
|
||||
assert agent_browser_runnable(str(bad)) is False
|
||||
|
||||
def test_not_executable_rejected(self, tmp_path):
|
||||
noexec = self._stub(tmp_path, "agent-browser", "#!/bin/sh\necho hi\n", mode=0o644)
|
||||
assert agent_browser_runnable(str(noexec)) is False
|
||||
|
||||
def test_npx_fallback_form_accepted(self):
|
||||
# The "npx agent-browser" command form is not a real file; npx resolves
|
||||
# the package at run time, so the validator trusts it without stat.
|
||||
assert agent_browser_runnable("npx agent-browser") is True
|
||||
assert agent_browser_runnable("/usr/local/bin/npx agent-browser") is True
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ class TestFindAgentBrowserCache:
|
|||
|
||||
def test_cached_after_first_call(self):
|
||||
import tools.browser_tool as bt
|
||||
with patch("shutil.which", return_value="/usr/bin/agent-browser"):
|
||||
with patch("shutil.which", return_value="/usr/bin/agent-browser"), \
|
||||
patch("tools.browser_tool.agent_browser_runnable", return_value=True):
|
||||
result1 = bt._find_agent_browser()
|
||||
result2 = bt._find_agent_browser()
|
||||
assert result1 == result2 == "/usr/bin/agent-browser"
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ class TestFindAgentBrowser:
|
|||
|
||||
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"):
|
||||
with patch("shutil.which", return_value="/usr/local/bin/agent-browser"), \
|
||||
patch("tools.browser_tool.agent_browser_runnable", return_value=True):
|
||||
assert _find_agent_browser() == "/usr/local/bin/agent-browser"
|
||||
|
||||
def test_finds_in_homebrew_bin(self):
|
||||
|
|
@ -112,6 +113,7 @@ class TestFindAgentBrowser:
|
|||
return None
|
||||
|
||||
with patch("shutil.which", side_effect=mock_which), \
|
||||
patch("tools.browser_tool.agent_browser_runnable", return_value=True), \
|
||||
patch("os.path.isdir", return_value=True), \
|
||||
patch(
|
||||
"tools.browser_tool._discover_homebrew_node_dirs",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue