Ryker_Feng fa496c0c8d
feat(browser): add agentic browser control (#4187)
* feat(browser): add agentic browser control

* fix(frontend): format browser view changes

* fix(browser): keep browser optional and isolate sidecar layout

* fix(browser): address PR review security and IME findings

- Nginx: add a browser-stream WebSocket location before the generic
  /api/threads regex so Live upgrades instead of downgrading to HTTP
  (both nginx.conf and nginx.local.conf).
- Ownership: require an existing owned thread for the WS stream and REST
  navigate, and tear down the browser session on thread deletion so a
  later caller cannot reuse a retained page/cookies by guessing the id.
- SSRF: enforce the URL policy at the browser request boundary via a
  context-level route guard covering redirects, popups, iframes, and
  subresources (skipped for CDP-attached Chrome).
- IME: skip key forwarding while a composition is active so confirming a
  CJK candidate with Enter no longer submits the remote page form.

Adds regression tests for the request guard, session teardown on delete,
and the composing-Enter key decision.

* fix(frontend): smooth streaming in long tool threads

* Revert "fix(frontend): smooth streaming in long tool threads"

This reverts commit f0462516eabe77f138d4027ea1c714fb226683cf.

* fix(browser): address review security and lifecycle findings

- Reject cross-origin WebSocket upgrades on the live browser stream
  (Origin allow-list reuse of CORS/same-origin helpers) to close a
  WS-CSRF hole, and fail closed when the ownership store is absent.
- Warn when a CDP-attached session runs with the SSRF request guard
  off, and drop the unreachable CDP screencast teardown dead code.
- Read browser session launch config from a single canonical source
  (browser_navigate) so it is deterministic regardless of call order.
- Bound per-thread Chromium accumulation with idle-timeout eviction
  and an LRU max-sessions cap.
- Reset the Live reconnect counter on a successful open so the stream
  can't permanently stall after the cumulative attempt cap.

* fix(frontend): reduce long tool thread render stalls

Reuse stable historical message groups during streaming, defer heavy Markdown and browser previews, and lazy-decode message images.

* fix(browser): keep live control responsive during continuous input

Why: Manual browser control felt laggy — a physical click ran the remote
Playwright click three times and each non-move input synchronously awaited a
JPEG screenshot, so events queued behind capture (queue wait up to ~237ms).
The first async attempt used a trailing-edge debounce, which froze the visible
page until a wheel/keyboard gesture stopped ("scroll finishes, then it jumps").

What:
- Frontend forwards one `click` per physical click instead of also emitting
  `down`/`up`, so the remote page is not clicked twice per gesture.
- Backend detaches live-frame capture from input dispatch: non-move actions
  start a rate-limited background refresh loop (leading frame + bounded cadence)
  that keeps emitting frames while input continues and never blocks dispatch.
- Add regression tests: input dispatch no longer awaits the screenshot, rapid
  inputs coalesce, and continuous input keeps refreshing before it stops.

Scenarios: Verified in the live Browser panel — a single click completes in
~57ms (was blocked behind a 171ms capture), and a 1.14s sustained wheel gesture
renders ~7 frames throughout the scroll instead of one frame after it ends.

* fix(browser): harden worker and session lifecycle

* fix(browser): address latest review feedback

* fix(frontend): preserve optimistic new-chat message

* test(e2e): preserve mocked message run ids

* fix(browser): address capability review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-21 11:46:33 +08:00

449 lines
19 KiB
Python

"""Agentic browser tools — a stateful navigate → observe → act loop.
Unlike the read-only ``web_fetch`` / ``web_capture`` tools, these keep a live
per-thread browser session (see :mod:`.session`) so the agent can click, type,
submit forms, and follow multi-step flows on JavaScript-heavy or authenticated
pages. Every action returns a fresh page snapshot whose interactive elements are
addressed by a stable ``[ref]`` index, so the model acts on what it just
observed instead of guessing selectors.
All URLs are SSRF-screened with the shared :func:`validate_public_http_url`
helper (opt-out only for intentional internal targets).
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated
from langchain.tools import InjectedToolCallId, tool
from langchain_core.messages import ToolMessage
from langgraph.types import Command
from deerflow.community.url_safety import resolve_host_addresses as _resolve_host_addresses
from deerflow.community.url_safety import validate_public_http_url
from deerflow.config import get_app_config
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.constants import BROWSER_FRAMES_DIRNAME
from deerflow.tools.types import Runtime
from .session import BrowserSession, BrowserSessionManager, PageSnapshot, get_browser_session_manager
logger = logging.getLogger(__name__)
_OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs"
# Auto-captured per-step screenshots are live progress feedback (shown in the
# browser panel + inline thumbnails), not deliverables. Keep them in a hidden
# subdir so the workspace-changes review does not list them as file changes.
# The dir name is a shared constant so the scanner's ignore list cannot drift.
_BROWSER_FRAMES_DIRNAME = BROWSER_FRAMES_DIRNAME
_FRAMES_VIRTUAL_PREFIX = f"{_OUTPUTS_VIRTUAL_PREFIX}/{_BROWSER_FRAMES_DIRNAME}"
_SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _get_tool_config(tool_name: str) -> dict:
config = get_app_config().get_tool_config(tool_name)
if config is None:
return {}
return config.model_extra or {}
def _as_bool(value: object, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
return default
def _as_int(value: object, default: int) -> int:
if isinstance(value, int) and not isinstance(value, bool):
return value
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return default
return default
def _thread_id(runtime: Runtime) -> str | None:
return runtime.context.get("thread_id") if runtime.context else None
def _as_str(value: object) -> str | None:
if isinstance(value, str):
trimmed = value.strip()
return trimmed or None
return None
class _SessionLease:
"""Context manager that keeps a process-local browser session pinned."""
def __init__(self, manager: BrowserSessionManager, thread_id: str | None, session: BrowserSession) -> None:
self._manager = manager
self._thread_id = thread_id
self.session = session
def __enter__(self) -> BrowserSession:
return self.session
def __exit__(self, exc_type, exc_value, traceback) -> None:
self._manager.release_session(self._thread_id, self.session)
def _resolve_session(runtime: Runtime, tool_name: str) -> _SessionLease:
# Launch config (headless/viewport/timeout/cdp_url) is read from a single
# canonical source — always ``browser_navigate`` — regardless of which tool
# first creates the session. ``get_session`` caches per thread and ignores
# these params for later callers, so keying launch config off the calling
# tool made it "first tool to run wins": a ``headless: false`` set only on
# ``browser_navigate`` was silently dropped if another tool (or the live WS)
# initialized the session first. ``tool_name`` is retained for callers that
# read their own non-launch config (e.g. ``browser_get_text``'s max_chars).
del tool_name
cfg = _get_tool_config("browser_navigate")
headless = _as_bool(cfg.get("headless"), True)
timeout_ms = _as_int(cfg.get("timeout_ms"), 30000)
width = _as_int(cfg.get("viewport_width"), 1280)
height = _as_int(cfg.get("viewport_height"), 720)
cdp_url = _as_str(cfg.get("cdp_url"))
manager = get_browser_session_manager()
thread_id = _thread_id(runtime)
session = manager.get_session(
thread_id,
headless=headless,
timeout_ms=timeout_ms,
viewport={"width": width, "height": height},
cdp_url=cdp_url,
allow_unguarded_cdp=_as_bool(cfg.get("allow_unguarded_cdp"), False),
url_guard=validate_browser_url,
pin=True,
)
return _SessionLease(manager, thread_id, session)
def validate_browser_url(url: str, *, tool_name: str = "browser_navigate") -> str | None:
"""SSRF-screen a browser navigation URL using the tool's config policy.
Returns an ``"Error: ..."`` string when the URL must be rejected, or ``None``
when navigation may proceed. Shared by the agent tools and the Gateway live
stream so every path that can steer the browser enforces the same allow/deny
policy (``allow_private_addresses`` from the ``browser_navigate`` tool config).
"""
cfg = _get_tool_config(tool_name)
allow_private = _as_bool(cfg.get("allow_private_addresses"), False)
return validate_public_http_url(
url,
allow_private_addresses=allow_private,
action="browse",
resolver=_resolve_host_addresses,
)
def _validate_url(tool_name: str, url: str) -> str | None:
return validate_browser_url(url, tool_name=tool_name)
def _snapshot_message(snapshot: PageSnapshot, prefix: str = "") -> str:
body = snapshot.render()
return f"{prefix}\n\n{body}" if prefix else body
def _tool_message(content: str, tool_call_id: str) -> Command:
return Command(update={"messages": [ToolMessage(content, tool_call_id=tool_call_id)]})
def _step_screenshot_name(action: str) -> str:
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f")
safe = _SAFE_FILENAME_RE.sub("_", action).strip("._-") or "step"
return f"browser-{safe}-{stamp}.png"
async def _capture_step_screenshot(runtime: Runtime, session: BrowserSession, action: str) -> str | None:
"""Best-effort per-action screenshot saved as hidden live-progress feedback.
Returns the artifact virtual path (under the hidden ``.browser-frames`` dir so
it stays out of the workspace-changes review), or ``None`` when outputs are
unavailable or capture fails — a failed capture must never break the action.
"""
outputs_path = _thread_outputs_path(runtime)
if isinstance(outputs_path, str):
return None
try:
content = await session.screenshot_bytes(full_page=False)
name = _step_screenshot_name(action)
frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME
final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content)
with contextlib.suppress(Exception):
session.schedule_live_frames()
return f"{_FRAMES_VIRTUAL_PREFIX}/{final_name}"
except Exception as e:
logger.warning(f"browser step screenshot failed: {e}")
return None
def _snapshot_command(
runtime: Runtime,
session: BrowserSession,
snapshot: PageSnapshot,
tool_call_id: str,
prefix: str,
screenshot_path: str | None,
) -> Command:
"""Build a ToolMessage carrying the text snapshot plus an inline screenshot.
The screenshot rides both as a thread ``artifacts`` entry (so it opens in the
artifacts side panel) and on ``ToolMessage.additional_kwargs.browser_view``
(so the chat can render an inline thumbnail per browser step).
"""
text = _snapshot_message(snapshot, prefix)
additional_kwargs: dict = {}
update: dict = {}
if screenshot_path:
additional_kwargs["browser_view"] = {"screenshot": screenshot_path, "url": snapshot.url, "title": snapshot.title}
update["artifacts"] = [screenshot_path]
update["messages"] = [ToolMessage(text, tool_call_id=tool_call_id, additional_kwargs=additional_kwargs)]
return Command(update=update)
async def navigate_and_capture(*, thread_id: str | None, url: str, outputs_path: Path) -> dict:
"""Drive the per-thread browser session to *url* and capture a screenshot.
Used by the Gateway browser router so a user can steer the live session from
the UI URL bar. Shares the same per-thread session, SSRF policy, and
screenshot pipeline as :func:`browser_navigate_tool`.
Returns ``{"screenshot": virtual_path|None, "url": str, "title": str}``.
Raises :class:`ValueError` when the URL fails SSRF validation.
"""
url_error = _validate_url("browser_navigate", url)
if url_error:
raise ValueError(url_error)
cfg = _get_tool_config("browser_navigate")
manager = get_browser_session_manager()
with manager.acquire_session(
thread_id,
headless=_as_bool(cfg.get("headless"), True),
timeout_ms=_as_int(cfg.get("timeout_ms"), 30000),
viewport={"width": _as_int(cfg.get("viewport_width"), 1280), "height": _as_int(cfg.get("viewport_height"), 720)},
cdp_url=_as_str(cfg.get("cdp_url")),
allow_unguarded_cdp=_as_bool(cfg.get("allow_unguarded_cdp"), False),
url_guard=validate_browser_url,
) as session:
snapshot = await session.navigate(url)
screenshot_path: str | None = None
try:
content = await session.screenshot_bytes(full_page=False)
name = _step_screenshot_name("navigate")
frames_dir = outputs_path / _BROWSER_FRAMES_DIRNAME
final_name = await asyncio.to_thread(_write_screenshot, frames_dir, name, content)
screenshot_path = f"{_FRAMES_VIRTUAL_PREFIX}/{final_name}"
except Exception as e:
logger.warning(f"browser gateway navigate screenshot failed: {e}")
return {"screenshot": screenshot_path, "url": snapshot.url, "title": snapshot.title}
@tool("browser_navigate", parse_docstring=True)
async def browser_navigate_tool(runtime: Runtime, url: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Open a URL in a live browser session and return the page's interactive elements.
Use this to START a browsing flow. Unlike web_fetch (read-only), this keeps a
stateful browser so you can then click and type on the page. The result lists
interactive elements as ``[ref] role: name`` — use those ``[ref]`` numbers with
browser_click and browser_type. The session persists across tool calls for this
conversation until browser_close. Every navigate/click/type step is
auto-captured as a screenshot the user can see, so you do not need to call
browser_screenshot just to show progress.
URLs must include the scheme, e.g. https://example.com.
Args:
url: The http(s) URL to open.
"""
try:
url_error = _validate_url("browser_navigate", url)
if url_error:
return _tool_message(url_error, tool_call_id)
with _resolve_session(runtime, "browser_navigate") as session:
snapshot = await session.navigate(url)
screenshot = await _capture_step_screenshot(runtime, session, "navigate")
return _snapshot_command(runtime, session, snapshot, tool_call_id, f"Navigated to {url}.", screenshot)
except Exception as e:
logger.error(f"browser_navigate failed: {e}")
return _tool_message(f"Error: browser navigation failed: {e}", tool_call_id)
@tool("browser_snapshot")
async def browser_snapshot_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Re-read the current page's interactive elements without acting. Use this to refresh the [ref] element list after the page changed on its own (e.g. async content loaded) or when you are unsure of the current state."""
try:
with _resolve_session(runtime, "browser_snapshot") as session:
snapshot = await session.snapshot()
screenshot = await _capture_step_screenshot(runtime, session, "snapshot")
return _snapshot_command(runtime, session, snapshot, tool_call_id, "", screenshot)
except Exception as e:
logger.error(f"browser_snapshot failed: {e}")
return _tool_message(f"Error: browser snapshot failed: {e}", tool_call_id)
@tool("browser_click", parse_docstring=True)
async def browser_click_tool(runtime: Runtime, ref: int, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Click an interactive element by its ``[ref]`` number from the latest snapshot.
The ref comes from the numbered element list returned by browser_navigate,
browser_snapshot, browser_click, or browser_type. Returns the updated page
snapshot after the click (with new ``[ref]`` numbers).
Args:
ref: The element reference number to click.
"""
try:
with _resolve_session(runtime, "browser_click") as session:
snapshot = await session.click(ref)
screenshot = await _capture_step_screenshot(runtime, session, "click")
return _snapshot_command(runtime, session, snapshot, tool_call_id, f"Clicked element [{ref}].", screenshot)
except Exception as e:
logger.error(f"browser_click failed: {e}")
return _tool_message(f"Error: could not click element [{ref}]: {e}", tool_call_id)
@tool("browser_type", parse_docstring=True)
async def browser_type_tool(
runtime: Runtime,
ref: int,
text: str,
tool_call_id: Annotated[str, InjectedToolCallId],
submit: bool = False,
) -> Command:
"""Type text into an input/textarea element by its ``[ref]`` number.
Fills the field identified by ref. Set submit=true to press Enter afterward
(e.g. to run a search or submit a form). Returns the updated page snapshot.
Args:
ref: The element reference number of the input to type into.
text: The text to enter.
submit: When true, press Enter after typing to submit.
"""
try:
with _resolve_session(runtime, "browser_type") as session:
snapshot = await session.type_text(ref, text, submit=submit)
action = f"Typed into element [{ref}] and submitted." if submit else f"Typed into element [{ref}]."
screenshot = await _capture_step_screenshot(runtime, session, "type")
return _snapshot_command(runtime, session, snapshot, tool_call_id, action, screenshot)
except Exception as e:
logger.error(f"browser_type failed: {e}")
return _tool_message(f"Error: could not type into element [{ref}]: {e}", tool_call_id)
@tool("browser_get_text")
async def browser_get_text_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Read the visible text content of the current page. Use this to extract readable text after navigating/interacting, e.g. to quote results or summarize content. Output is truncated for large pages."""
try:
with _resolve_session(runtime, "browser_get_text") as session:
cfg = _get_tool_config("browser_get_text")
max_chars = _as_int(cfg.get("max_chars"), 8000)
text = await session.get_text(max_chars=max_chars)
return _tool_message(text or "(page has no visible text)", tool_call_id)
except Exception as e:
logger.error(f"browser_get_text failed: {e}")
return _tool_message(f"Error: could not read page text: {e}", tool_call_id)
@tool("browser_back")
async def browser_back_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Go back to the previous page in the browser session's history."""
try:
with _resolve_session(runtime, "browser_back") as session:
snapshot = await session.back()
screenshot = await _capture_step_screenshot(runtime, session, "back")
return _snapshot_command(runtime, session, snapshot, tool_call_id, "Went back.", screenshot)
except Exception as e:
logger.error(f"browser_back failed: {e}")
return _tool_message(f"Error: could not go back: {e}", tool_call_id)
def _safe_screenshot_name(filename: str | None) -> str:
if filename:
stem = Path(filename).stem or "browser-capture"
else:
stem = f"browser-capture-{datetime.now(UTC).strftime('%Y%m%d-%H%M%S')}"
safe = _SAFE_FILENAME_RE.sub("_", stem).strip("._-") or "browser-capture"
return f"{safe[:100]}.png"
def _thread_outputs_path(runtime: Runtime) -> Path | str:
if runtime.state is None:
return "Error: Thread runtime state is not available"
thread_data = runtime.state.get("thread_data") or {}
outputs_path = thread_data.get("outputs_path")
if not outputs_path:
return "Error: Thread outputs path is not available"
return Path(outputs_path)
def _write_screenshot(outputs_path: Path, name: str, content: bytes) -> str:
outputs_path.mkdir(parents=True, exist_ok=True)
(outputs_path / name).write_bytes(content)
return name
@tool("browser_screenshot", parse_docstring=True)
async def browser_screenshot_tool(
runtime: Runtime,
tool_call_id: Annotated[str, InjectedToolCallId],
filename: str | None = None,
full_page: bool = False,
) -> Command:
"""Capture a screenshot of the current browser page and save it as an artifact.
Use this for visual evidence of the current interactive session state (after
clicking/typing), which web_capture cannot provide because it renders a fresh,
stateless page load.
Args:
filename: Optional output filename (extension is forced to .png).
full_page: Capture the full scrollable page instead of just the viewport.
"""
try:
outputs_path = _thread_outputs_path(runtime)
if isinstance(outputs_path, str):
return _tool_message(outputs_path, tool_call_id)
with _resolve_session(runtime, "browser_screenshot") as session:
content = await session.screenshot_bytes(full_page=full_page)
name = _safe_screenshot_name(filename)
final_name = await asyncio.to_thread(_write_screenshot, outputs_path, name, content)
virtual_path = f"{_OUTPUTS_VIRTUAL_PREFIX}/{final_name}"
return Command(
update={
"artifacts": [virtual_path],
"messages": [ToolMessage(f"Saved browser screenshot: {virtual_path}", tool_call_id=tool_call_id)],
}
)
except Exception as e:
logger.error(f"browser_screenshot failed: {e}")
return _tool_message(f"Error: could not capture screenshot: {e}", tool_call_id)
@tool("browser_close")
async def browser_close_tool(runtime: Runtime, tool_call_id: Annotated[str, InjectedToolCallId]) -> Command:
"""Close the current browser session and free its resources. Call this when done with the browsing flow; a later browser_navigate starts a fresh session."""
try:
manager = get_browser_session_manager()
closed = await manager.close_session(_thread_id(runtime))
msg = "Browser session closed." if closed else "No active browser session to close."
return _tool_message(msg, tool_call_id)
except Exception as e:
logger.error(f"browser_close failed: {e}")
return _tool_message(f"Error: could not close browser session: {e}", tool_call_id)