fix(browser): floor browser-use CLI subprocess PATH with sane system dirs
Profile-spawned workers (kanban bots, cron jobs) can inherit a PATH of only version-manager dirs — observed in the wild as one nvm node dir repeated 7x. The uv-installed browser-use binary is a POSIX sh trampoline that resolves dirname/realpath through PATH, so it died with 'realpath: not found … exec: /python: not found' (exit 127) before its own Python ever started. _base_subprocess_env now floors the child PATH via browser_tool's _merge_browser_path (the agent-browser backend already guards the same hazard), degrading to appending FHS bin dirs if that import is ever unavailable. Windows is a no-op (.cmd shims don't trampoline). Verified: unit tests + real uvx browser-use --version under a nvm-only-PATH worker env, rc 127 -> rc 0.
This commit is contained in:
@@ -123,6 +123,60 @@ class TestSubprocessEnvironment:
|
||||
assert "PYTHONHOME" not in env
|
||||
assert env["KEEP_ME"] == "yes"
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX PATH-floor semantics")
|
||||
def test_subprocess_env_floors_version_manager_only_path(self, monkeypatch):
|
||||
"""Profile workers (kanban bots, cron) can inherit a PATH of only
|
||||
version-manager dirs (observed in the wild: one nvm dir repeated
|
||||
7x). The uv browser-use trampoline resolves dirname/realpath
|
||||
through PATH, so /usr/bin must be guaranteed or the CLI dies
|
||||
'realpath: not found' (exit 127) before its Python starts."""
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
browser_tool = ModuleType("tools.browser_tool")
|
||||
browser_tool._build_browser_env = lambda: {
|
||||
"PATH": os.pathsep.join(
|
||||
["/home/u/.nvm/versions/node/v24.18.0/bin"] * 7
|
||||
),
|
||||
}
|
||||
monkeypatch.setitem(sys.modules, "tools.browser_tool", browser_tool)
|
||||
|
||||
env = bu_cli._base_subprocess_env()
|
||||
|
||||
parts = env["PATH"].split(os.pathsep)
|
||||
assert "/usr/bin" in parts
|
||||
assert "/bin" in parts
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX PATH-floor semantics")
|
||||
def test_floor_preserves_existing_entries_and_order(self):
|
||||
"""The floor only adds dirs — never drops or reorders what the
|
||||
caller's environment already had."""
|
||||
original = "/opt/toolchain/bin:/usr/bin:/snap/bin"
|
||||
merged = bu_cli._floor_subprocess_path(original).split(os.pathsep)
|
||||
|
||||
assert set(original.split(os.pathsep)) <= set(merged)
|
||||
positions = [merged.index(p) for p in original.split(os.pathsep)]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX PATH-floor semantics")
|
||||
def test_floor_survives_missing_sibling_helper(self, monkeypatch):
|
||||
"""If browser_tool stops exporting _merge_browser_path, the floor
|
||||
degrades to appending FHS bin dirs instead of vanishing."""
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
browser_tool = ModuleType("tools.browser_tool")
|
||||
browser_tool._build_browser_env = lambda: {
|
||||
"PATH": "/home/u/.nvm/versions/node/v24.18.0/bin"
|
||||
}
|
||||
monkeypatch.setitem(sys.modules, "tools.browser_tool", browser_tool)
|
||||
|
||||
env = bu_cli._base_subprocess_env()
|
||||
|
||||
parts = env["PATH"].split(os.pathsep)
|
||||
assert "/usr/bin" in parts
|
||||
assert "/home/u/.nvm/versions/node/v24.18.0/bin" in parts
|
||||
|
||||
|
||||
class TestToolSurfaceSwap:
|
||||
def test_legacy_browser_tools_hidden_in_cli_mode(self, monkeypatch):
|
||||
|
||||
@@ -118,10 +118,51 @@ def _base_subprocess_env() -> dict:
|
||||
# needs Hermes's import path.
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
# Same class of hazard, PATH flavor: profile-spawned workers (kanban
|
||||
# bots, cron jobs) can hand down a PATH of only version-manager dirs,
|
||||
# which kills the uv trampoline before the CLI's Python starts. Floor
|
||||
# the PATH so coreutils are always reachable (see below).
|
||||
env["PATH"] = _floor_subprocess_path(env.get("PATH", ""))
|
||||
env.setdefault("ANONYMIZED_TELEMETRY", "false")
|
||||
return env
|
||||
|
||||
|
||||
def _floor_subprocess_path(path: str) -> str:
|
||||
"""Guarantee core system dirs survive onto the CLI subprocess PATH.
|
||||
|
||||
Profile workers can inherit a PATH holding only version-manager dirs
|
||||
(observed: the nvm node dir repeated 7x, nothing else). That is fatal
|
||||
for the uv-installed browser-use binary: its POSIX sh trampoline
|
||||
resolves ``dirname``/``realpath`` through PATH, so without /usr/bin it
|
||||
dies with ``realpath: not found … exec: /python: not found`` (exit
|
||||
127) before its own Python ever starts. Reuses browser_tool's
|
||||
``_merge_browser_path`` floor — same hazard, same sane-dir list — and
|
||||
falls back to appending FHS bin dirs if that import is unavailable.
|
||||
Windows .cmd shims don't trampoline through PATH, so no-op there.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
return path
|
||||
try:
|
||||
from tools.browser_tool import _merge_browser_path
|
||||
|
||||
return _merge_browser_path(path or "")
|
||||
except Exception:
|
||||
pass
|
||||
parts = [p for p in (path or "").split(os.pathsep) if p]
|
||||
existing = set(parts)
|
||||
for directory in (
|
||||
"/usr/local/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/sbin",
|
||||
"/usr/bin",
|
||||
"/sbin",
|
||||
"/bin",
|
||||
):
|
||||
if directory not in existing and os.path.isdir(directory):
|
||||
parts.append(directory)
|
||||
return os.pathsep.join(parts)
|
||||
|
||||
|
||||
def _read_browser_cfg() -> dict:
|
||||
"""Return the ``browser:`` config section, or {} on any failure."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user