Plugins that drive the agent's browser below the browser tools (Secure ENV, catalog review on #132272) had no public way to send a raw CDP command over a task's supervisor connection, so they bumped CDPSupervisor._next_call_id, inserted futures into _pending_calls and sent on _ws. That breaks silently on any internal change and collides between plugins. SUPERVISOR_REGISTRY.capture(task_id) now returns a CapturedCDP pinned to the supervisor's current socket and default page session: call(method, params=None, *, session_id=None, timeout=10.0) -> raw reply is_valid() / page_session_id / task_id / cdp_url The validity check and the send run in one supervisor-loop step, so a call never reaches a newer connection: after a reconnect, stop or registry replacement every call raises CapturedCDPInvalid. Calls in flight when the socket drops now fail at once (the supervisor fails pending replies on teardown) instead of sitting out their timeout. No model tool, config default or new capability: in-process Python could already reach this. The handle enforces no origin/consent/ownership policy. Reduced-scope salvage of #132752 (API shape and names from that PR); its attach-reply handoff, old-wire cleanup and dispatch fences are not needed because the supervisor closes the old socket on every reconnect, which drops its sessions with it. Co-authored-by: web3blind <264741654+web3blind@users.noreply.github.com>
116 lines
5.5 KiB
Python
116 lines
5.5 KiB
Python
"""Captured CDP connection handle for trusted in-process plugins.
|
|
|
|
``SUPERVISOR_REGISTRY.capture(task_id)`` pins one supervisor's current WebSocket and the
|
|
default page session attached on it. ``call()`` sends only over that socket. Once the
|
|
supervisor reconnects, stops, or is replaced in the registry, the handle is invalid and
|
|
every call raises ``CapturedCDPInvalid``: it never retargets a newer connection.
|
|
|
|
This grants no new capability. In-process plugin code can already reach the supervisor;
|
|
the handle exists so plugins stop writing its private call table and socket (catalog
|
|
review on #132272). It performs no origin, consent or target-ownership checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import concurrent.futures
|
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
|
|
if TYPE_CHECKING:
|
|
from tools.browser_supervisor import CDPSupervisor, _SupervisorRegistry
|
|
|
|
|
|
class CapturedCDPInvalid(RuntimeError):
|
|
"""The captured connection is gone (reconnect, stop, or registry replacement)."""
|
|
|
|
|
|
class CapturedCDP:
|
|
"""A CDP channel pinned to one supervisor connection. Build via ``SUPERVISOR_REGISTRY.capture``."""
|
|
|
|
def __init__(self, supervisor: CDPSupervisor, registry: _SupervisorRegistry) -> None:
|
|
# Runs on the supervisor loop (see ``capture``) so ws and session id are one snapshot.
|
|
self._supervisor = supervisor
|
|
self._registry = registry
|
|
self._loop: asyncio.AbstractEventLoop = supervisor._loop # type: ignore[assignment]
|
|
self._ws = supervisor._ws
|
|
self.task_id: str = supervisor.task_id
|
|
self.cdp_url: str = supervisor.cdp_url
|
|
# The default page session at capture time. A later ``focus_page`` on the supervisor
|
|
# does not change it: calls with this id keep reaching the page captured here.
|
|
self.page_session_id: str = supervisor._page_session_id or ""
|
|
|
|
def is_valid(self) -> bool:
|
|
"""True while the captured connection is still the supervisor's live one."""
|
|
sup = self._supervisor
|
|
return (self._registry.get(self.task_id) is sup and sup._active and not sup._stop_requested
|
|
and sup._ws is self._ws and self._loop.is_running())
|
|
|
|
def _invalid(self) -> CapturedCDPInvalid:
|
|
return CapturedCDPInvalid(f"captured CDP connection for task {self.task_id!r} is gone "
|
|
"(supervisor reconnected, stopped or was replaced); capture again")
|
|
|
|
async def _send(self, method: str, params: Optional[Dict[str, Any]],
|
|
session_id: Optional[str], timeout: float) -> Dict[str, Any]:
|
|
# ``_cdp`` reaches its send without awaiting, so this check and the send run in one
|
|
# loop step: a reconnect cannot swap the socket in between.
|
|
if not self.is_valid():
|
|
raise self._invalid()
|
|
try:
|
|
return await self._supervisor._cdp(method, params, session_id=session_id, timeout=timeout)
|
|
except Exception:
|
|
# A reply lost to a dropped socket (timeout, ConnectionError, ConnectionClosed)
|
|
# surfaces as invalidation; a CDP error on a live connection passes through.
|
|
if not self.is_valid():
|
|
raise self._invalid() from None
|
|
raise
|
|
|
|
def call(self, method: str, params: Optional[Dict[str, Any]] = None, *,
|
|
session_id: Optional[str] = None, timeout: float = 10.0) -> Dict[str, Any]:
|
|
"""Send ``method`` on the captured connection and return the raw CDP reply
|
|
(``{"id", "result"}``). ``session_id=None`` targets the browser endpoint;
|
|
pass ``page_session_id`` (or a session you attached) for page domains.
|
|
|
|
Raises ``CapturedCDPInvalid`` when the connection is gone, ``TimeoutError`` when no
|
|
reply arrives in ``timeout`` seconds, ``RuntimeError`` for a CDP error reply.
|
|
Blocks the calling thread; never call it from the supervisor's own loop.
|
|
"""
|
|
try:
|
|
running = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
running = None
|
|
if running is self._loop:
|
|
raise RuntimeError("CapturedCDP.call() blocks and cannot run on the supervisor loop")
|
|
if not self.is_valid():
|
|
raise self._invalid()
|
|
from agent.async_utils import safe_schedule_threadsafe
|
|
|
|
fut = safe_schedule_threadsafe(self._send(method, params, session_id, timeout), self._loop)
|
|
if fut is None:
|
|
raise self._invalid()
|
|
try:
|
|
return fut.result(timeout=timeout + 1.0)
|
|
except concurrent.futures.TimeoutError:
|
|
fut.cancel()
|
|
raise TimeoutError(f"CDP {method} got no reply within {timeout}s") from None
|
|
|
|
|
|
def capture(registry: _SupervisorRegistry, task_id: str, *, timeout: float = 10.0) -> CapturedCDP:
|
|
"""Pin the running supervisor for ``task_id``; never starts, reconnects or refocuses one."""
|
|
from tools.browser_supervisor import _LoopUnavailable, _schedule
|
|
|
|
found = registry.get(task_id)
|
|
loop = found._loop if found is not None else None
|
|
if found is None or loop is None or not loop.is_running():
|
|
raise CapturedCDPInvalid(f"no running browser supervisor for task {task_id!r}")
|
|
sup = found
|
|
|
|
async def _snapshot() -> CapturedCDP:
|
|
if not sup._active or sup._ws is None or not sup._page_session_id:
|
|
raise CapturedCDPInvalid(f"browser supervisor for task {task_id!r} has no attached page")
|
|
return CapturedCDP(sup, registry)
|
|
|
|
try:
|
|
return _schedule(_snapshot(), loop, timeout=timeout)
|
|
except _LoopUnavailable:
|
|
raise CapturedCDPInvalid(f"no running browser supervisor for task {task_id!r}") from None
|