fix(bot-screen): fence browser_exec behind human lease
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user