diff --git a/tests/tools/test_bot_desktop_browser_fence.py b/tests/tools/test_bot_desktop_browser_fence.py index 1d8ba3ae5d..0222165eca 100644 --- a/tests/tools/test_bot_desktop_browser_fence.py +++ b/tests/tools/test_bot_desktop_browser_fence.py @@ -4,6 +4,7 @@ dispatched, and a command whose run crossed a takeover loses its result.""" from __future__ import annotations import json +import subprocess import pytest @@ -38,6 +39,57 @@ def _wire(monkeypatch, commands): return browser, session +def _wire_browser_exec(monkeypatch, run_cli): + """Route browser_exec through local Chromium without starting a real browser.""" + from tools import browser_tool_cloud as cloud + from tools import browser_tool_session as session + from tools import browser_use_cli as browser_use + + monkeypatch.setattr(browser_use, "_find_cli", lambda: ["browser-use"]) + monkeypatch.setattr(browser_use, "_base_subprocess_env", lambda: {}) + monkeypatch.setattr(browser_use, "_real_profile_consented", lambda: False) + monkeypatch.setattr(browser_use, "_resolve_lightpanda_cdp", lambda *a: None) + monkeypatch.setattr("tools.browser_tool_cdp._get_cdp_override", lambda: "") + monkeypatch.setattr("tools.browser_tool._get_open_command_timeout", lambda **_kw: 5) + monkeypatch.setattr(cloud, "_get_cloud_provider", lambda: None) + monkeypatch.setattr(session, "_run_browser_command", lambda *_a, **_kw: { + "success": True, "data": {"cdpUrl": "http://127.0.0.1:9222"}}) + monkeypatch.setattr(browser_use, "_attach_vault_supervisor", lambda *a: None) + monkeypatch.setattr(browser_use, "_run_cli_killing_process_group", run_cli) + return browser_use + + +def test_browser_exec_is_fenced_while_human_controls_shared_browser(monkeypatch): + dispatched: list[str] = [] + + def run_cli(*_args): + dispatched.append("browser-use") + return subprocess.CompletedProcess([], 0, "WHAT-THE-HUMAN-TYPED", "") + + browser_use = _wire_browser_exec(monkeypatch, run_cli) + lease.acquire("human-viewer") + raw = browser_use.browser_exec("print(page_info())", task_id="review") + assert isinstance(raw, str) + result = json.loads(raw) + assert dispatched == [], "human holds the lease, yet browser_exec was dispatched" + assert "WHAT-THE-HUMAN-TYPED" not in raw + assert result.get("code") == "human_has_control" + + +def test_browser_exec_result_crossing_a_takeover_is_discarded(monkeypatch): + def run_cli(*_args): + lease.acquire("human-viewer") + lease.release("human-viewer") + return subprocess.CompletedProcess([], 0, "WHAT-THE-HUMAN-TYPED", "") + + browser_use = _wire_browser_exec(monkeypatch, run_cli) + raw = browser_use.browser_exec("print(page_info())", task_id="review") + assert isinstance(raw, str) + result = json.loads(raw) + assert "WHAT-THE-HUMAN-TYPED" not in raw + assert result.get("code") == "human_has_control" + + def test_browser_click_is_fenced_while_human_controls_shared_browser(monkeypatch): commands: list = [] browser, _ = _wire(monkeypatch, commands) diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index a52ac1b0bb..3dc2840f7c 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -31,6 +31,9 @@ _SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") # Set on the env dict by the CDP resolvers when the resolved browser is EXCLUSIVE to this named session # (per-name provider / named BU cloud / Lightpanda). Popped before the subprocess launches — never exported. _PRIVATE_BROWSER_SENTINEL = "_HERMES_BU_PRIVATE_BROWSER" +# Internal route provenance: this exec resolved to a browser on the Bot Desktop display and must use +# the same human-control lease fence as the built-in browser tools. Popped before launching the CLI. +_BOT_DESKTOP_BROWSER_SENTINEL = "_HERMES_BU_BOT_DESKTOP_BROWSER" # Prepended to the model's code for named sessions on SHARED browsers (a /browser connect CDP override): the # harness daemon attaches to the first existing page at startup, so two fresh named daemons can land on the @@ -391,6 +394,7 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str ) if err is None: env[_PRIVATE_BROWSER_SENTINEL] = "1" + env[_BOT_DESKTOP_BROWSER_SENTINEL] = "1" return err @@ -416,6 +420,7 @@ def _resolve_managed_chromium_cdp(env: dict, task_id: Optional[str], session_nam "Run `hermes tools` → Browser Automation to (re)install Chromium, or switch backends.") _set_cdp_env(env, cdp) env[_PRIVATE_BROWSER_SENTINEL] = "1" # one Chromium per cache key: nothing to share a tab with + env[_BOT_DESKTOP_BROWSER_SENTINEL] = "1" return None @@ -505,6 +510,7 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]: cdp, err = _real_profile_cdp() if cdp and not err: _set_cdp_env(env, cdp) + env[_BOT_DESKTOP_BROWSER_SENTINEL] = "1" return err or None @@ -627,7 +633,7 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT route_err = _route_backend(env, session, task_id, bool(local)) if route_err: return tool_error(route_err) - _attach_vault_supervisor(env, task_id) + bot_desktop_browser = bool(env.pop(_BOT_DESKTOP_BROWSER_SENTINEL, None)) # SHARED browser (/browser connect CDP override): pin each named session to its own tab (see # _OWN_TAB_PREAMBLE). Private per-name browsers skip this — nothing to collide with. @@ -646,14 +652,30 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT timeout = _clamp_timeout(timeout_s) started = time.time() - try: - proc = _run_cli_killing_process_group(cmd, code, env, timeout) - except subprocess.TimeoutExpired: - return tool_error(f"browser-use exec timed out after {timeout}s. The daemon may still be working; retry " - f"with a larger timeout_s (max {_MAX_TIMEOUT_S}), or split the work into several calls that " - "append to workspace files — anything already written to the workspace is preserved.") - except OSError as e: - return tool_error(f"Failed to launch browser-use CLI: {e}") + + def dispatch() -> Dict[str, Any]: + _attach_vault_supervisor(env, task_id) + try: + return {"proc": _run_cli_killing_process_group(cmd, code, env, timeout)} + except subprocess.TimeoutExpired: + return {"error_result": tool_error( + f"browser-use exec timed out after {timeout}s. The daemon may still be working; retry " + f"with a larger timeout_s (max {_MAX_TIMEOUT_S}), or split the work into several calls that " + "append to workspace files — anything already written to the workspace is preserved." + )} + except OSError as e: + return {"error_result": tool_error(f"Failed to launch browser-use CLI: {e}")} + + if bot_desktop_browser: + from tools.browser_tool_session import run_fenced + dispatched = run_fenced({"features": {"local": True}}, dispatch) + else: + dispatched = dispatch() + if "proc" not in dispatched: + if "error_result" in dispatched: + return dispatched["error_result"] + return tool_result(dispatched) + proc = dispatched["proc"] result = {"success": proc.returncode == 0, "exit_code": proc.returncode, "output": proc.stdout} if workspace: