diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index aa1a3c44fb..497619b2ba 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -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): diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 294f11bc68..257ba2c945 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -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: