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:
justcarlosm
2026-08-23 12:10:40 -06:00
committed by Teknium
parent 8f9abc9873
commit 478a09c06b
2 changed files with 95 additions and 0 deletions
+54
View File
@@ -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):
+41
View File
@@ -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: