Files
hermes-agent/tests/tools/test_browser_supervisor_capture.py
Teknium 6bf7de73e1 feat(browser): public captured CDP call seam for trusted plugins
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>
2026-10-05 17:10:58 -07:00

147 lines
5.3 KiB
Python

"""Behavior contract for ``SUPERVISOR_REGISTRY.capture`` (public CDP seam for trusted plugins).
A real ``CDPSupervisor`` talks to a real local WebSocket server that speaks just enough CDP
(getTargets / attachToTarget / echo). Each connection gets its own page session id
(``S1``, ``S2``, ...) so the tests can tell which socket a frame travelled on.
"""
from __future__ import annotations
import asyncio
import json
import threading
import time
import pytest
from tools.browser_supervisor import _SupervisorRegistry
from tools.browser_supervisor_capture import CapturedCDPInvalid
class _FakeCDP:
def __init__(self) -> None:
self.frames: list[tuple[int, str, str | None]] = [] # (connection no, method, sessionId)
self.connections: list = []
self.url = ""
self.loop = asyncio.new_event_loop()
ready = threading.Event()
threading.Thread(target=self._serve, args=(ready,), daemon=True).start()
assert ready.wait(5)
def _serve(self, ready: threading.Event) -> None:
from websockets.asyncio.server import serve
async def main():
async with serve(self._handle, "127.0.0.1", 0) as server:
self.url = f"ws://127.0.0.1:{list(server.sockets)[0].getsockname()[1]}/devtools/browser/x"
ready.set()
await asyncio.Event().wait()
self.loop.run_until_complete(main())
async def _handle(self, ws) -> None:
self.connections.append(ws)
conn = len(self.connections)
async for raw in ws:
msg = json.loads(raw)
method, sid = msg["method"], msg.get("sessionId")
self.frames.append((conn, method, sid))
if method == "Test.hang":
continue
if method == "Test.error":
await ws.send(json.dumps({"id": msg["id"], "error": {"code": -32000, "message": "boom"}}))
continue
result = {"echo": method, "sessionId": sid, "params": msg.get("params")}
if method == "Target.getTargets":
result = {"targetInfos": [{"targetId": "T1", "type": "page", "url": "about:blank"}]}
elif method == "Target.attachToTarget":
result = {"sessionId": f"S{conn}"}
await ws.send(json.dumps({"id": msg["id"], "result": result}))
def drop_current(self) -> None:
asyncio.run_coroutine_threadsafe(self.connections[-1].close(), self.loop).result(5)
def methods_on(self, conn: int) -> list[str]:
return [m for c, m, _ in self.frames if c == conn]
@pytest.fixture
def cdp():
return _FakeCDP()
@pytest.fixture
def registry():
reg = _SupervisorRegistry()
yield reg
reg.stop_all()
def _wait(pred, timeout=10.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if pred():
return
time.sleep(0.02)
raise AssertionError("condition not reached")
def test_call_round_trips_on_captured_connection(cdp, registry):
registry.get_or_start("t", cdp.url, start_timeout=5)
handle = registry.capture("t")
assert handle.is_valid() and handle.page_session_id == "S1"
reply = handle.call("Runtime.evaluate", {"expression": "1"}, session_id=handle.page_session_id)
assert reply["result"] == {"echo": "Runtime.evaluate", "sessionId": "S1", "params": {"expression": "1"}}
assert handle.call("Target.getTargets")["result"]["targetInfos"][0]["targetId"] == "T1" # browser-level
with pytest.raises(RuntimeError, match="boom"):
handle.call("Test.error")
started = time.monotonic()
with pytest.raises(TimeoutError):
handle.call("Test.hang", timeout=0.3)
assert time.monotonic() - started < 2
assert handle.is_valid() # neither an error reply nor a timeout invalidates the handle
with pytest.raises(CapturedCDPInvalid):
registry.capture("no-such-task")
def test_handle_never_follows_a_reconnect_or_replacement(cdp, registry):
sup = registry.get_or_start("t", cdp.url, start_timeout=5)
old = registry.capture("t")
# A call in flight when the socket drops fails promptly as invalidation, not at its timeout.
errors: list = []
caller = threading.Thread(target=lambda: errors.append(_raises(lambda: old.call("Test.hang", timeout=30))))
caller.start()
_wait(lambda: "Test.hang" in cdp.methods_on(1))
started = time.monotonic()
cdp.drop_current()
caller.join(10)
assert errors and isinstance(errors[0], CapturedCDPInvalid), errors
assert time.monotonic() - started < 5
_wait(lambda: len(cdp.connections) == 2 and sup.snapshot().active)
assert not old.is_valid()
with pytest.raises(CapturedCDPInvalid):
old.call("Probe.afterReconnect", session_id=old.page_session_id)
assert "Probe.afterReconnect" not in cdp.methods_on(2) # never silently retargeted
fresh = registry.capture("t")
assert fresh.page_session_id == "S2"
assert fresh.call("Probe.fresh", session_id=fresh.page_session_id)["result"]["sessionId"] == "S2"
registry.stop("t") # stop / replacement in the registry invalidates too
assert not fresh.is_valid()
with pytest.raises(CapturedCDPInvalid):
fresh.call("Probe.afterStop")
def _raises(fn) -> BaseException | None:
try:
fn()
except BaseException as exc: # noqa: BLE001 — handed back to the asserting thread
return exc
return None