refactor(browser): compact sibling modules — supervisor event dispatch inline, dialog responder unified (_respond/_respond_quiet/_cdp_quiet), dead ConsoleEvent ring buffer removed, browser_use_cli _quiet helper
This commit is contained in:
@@ -296,7 +296,6 @@ class TestBrowserSupervisorRedaction:
|
||||
closed_by="agent",
|
||||
),),
|
||||
frame_tree={"top": {"frame_id": "f1", "url": "about:blank", "origin": "null", "is_oopif": False}},
|
||||
console_errors=(),
|
||||
active=True,
|
||||
cdp_url="ws://example.invalid/devtools/browser/mock",
|
||||
task_id="test",
|
||||
|
||||
+50
-122
@@ -32,7 +32,6 @@ _CDP_PRIVATE_PAGE_ALLOWED_METHODS = {
|
||||
"Page.stopLoading",
|
||||
}
|
||||
|
||||
|
||||
# method → result paths that are ALWAYS opaque base64 (protocol-declared binary).
|
||||
# redact_sensitive_text's Fernet pattern ("gAAAA" + base64 alphabet) can match
|
||||
# arbitrary spans inside such payloads and corrupt the decoded bytes; the payload
|
||||
@@ -55,49 +54,41 @@ _CDP_FLAGGED_BINARY_PATHS: Dict[str, tuple] = {
|
||||
}
|
||||
|
||||
|
||||
def _redact_cdp_output(
|
||||
value: Any,
|
||||
*,
|
||||
always_paths: tuple = (),
|
||||
flagged_paths: tuple = (),
|
||||
) -> Any:
|
||||
def _redact_cdp_output(value: Any, *, always_paths: tuple = (), flagged_paths: tuple = ()) -> Any:
|
||||
"""Redact browser-originated CDP result text; opaque bytes stay byte-identical.
|
||||
|
||||
Exemptions come ONLY from the calling method's spec as exact result paths.
|
||||
Path suffixes propagate only into the matching subtree, so ``base64Encoded``
|
||||
is honored solely as a sibling on the trusted carrier object — never as
|
||||
ambient trust a ``Runtime.evaluate`` by-value object could spoof (#94142).
|
||||
ambient trust a ``Runtime.evaluate`` by-value object could spoof.
|
||||
"""
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
if isinstance(value, str):
|
||||
return redact_sensitive_text(value, force=True)
|
||||
if isinstance(value, list):
|
||||
return [_redact_cdp_output(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_redact_cdp_output(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
base64_flagged = value.get("base64Encoded") is True
|
||||
if isinstance(value, (list, tuple)):
|
||||
return type(value)(_redact_cdp_output(item) for item in value)
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
base64_flagged = value.get("base64Encoded") is True
|
||||
|
||||
def leaf(paths: tuple, key: str) -> bool:
|
||||
return any(len(p) == 1 and p[0] == key for p in paths)
|
||||
def leaf(paths: tuple, key: str) -> bool:
|
||||
return any(len(p) == 1 and p[0] == key for p in paths)
|
||||
|
||||
def descend(paths: tuple, key: str) -> tuple:
|
||||
return tuple(p[1:] for p in paths if len(p) > 1 and p[0] == key)
|
||||
def descend(paths: tuple, key: str) -> tuple:
|
||||
return tuple(p[1:] for p in paths if len(p) > 1 and p[0] == key)
|
||||
|
||||
redacted: Dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
opaque = leaf(always_paths, key) or (leaf(flagged_paths, key) and base64_flagged)
|
||||
if isinstance(item, str) and opaque:
|
||||
redacted[key] = item
|
||||
else:
|
||||
redacted[key] = _redact_cdp_output(
|
||||
item, always_paths=descend(always_paths, key), flagged_paths=descend(flagged_paths, key),
|
||||
)
|
||||
return redacted
|
||||
|
||||
redacted: Dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
opaque = leaf(always_paths, key) or (leaf(flagged_paths, key) and base64_flagged)
|
||||
if isinstance(item, str) and opaque:
|
||||
redacted[key] = item
|
||||
else:
|
||||
redacted[key] = _redact_cdp_output(
|
||||
item,
|
||||
always_paths=descend(always_paths, key),
|
||||
flagged_paths=descend(flagged_paths, key),
|
||||
)
|
||||
return redacted
|
||||
return value
|
||||
|
||||
# ``websockets`` is a direct dependency; wrap so a stale env yields a clean error.
|
||||
try:
|
||||
@@ -117,13 +108,11 @@ def _run_async(coro):
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, coro)
|
||||
return future.result()
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
@@ -163,30 +152,25 @@ _METHOD_PARAM_GUARDS = {
|
||||
}
|
||||
|
||||
|
||||
def _browser_cdp_private_guard(
|
||||
*,
|
||||
task_id: str,
|
||||
method: str,
|
||||
params: Dict[str, Any],
|
||||
) -> Optional[str]:
|
||||
def _browser_cdp_private_guard(*, task_id: str, method: str, params: Dict[str, Any]) -> Optional[str]:
|
||||
"""Apply the browser SSRF/private-page guard to raw CDP calls.
|
||||
|
||||
Raw CDP shares the cloud/private-network boundary of ``browser_snapshot`` /
|
||||
``browser_console`` / ``browser_eval`` and must not become their bypass.
|
||||
Guard probes are best-effort; a probe failure never breaks local/custom CDP
|
||||
workflows.
|
||||
"""
|
||||
try:
|
||||
from tools import browser_tool as bt # type: ignore[import-not-found]
|
||||
|
||||
if not bt._eval_ssrf_guard_active(task_id): # type: ignore[attr-defined]
|
||||
return None
|
||||
|
||||
guard = _METHOD_PARAM_GUARDS.get(method)
|
||||
if guard is not None:
|
||||
probe, template = guard
|
||||
literal = probe(bt, params or {})
|
||||
if literal:
|
||||
return _blocked(template.format(literal), method)
|
||||
|
||||
if method not in _CDP_PRIVATE_PAGE_ALLOWED_METHODS:
|
||||
blocked_url = bt._current_page_private_url(task_id) # type: ignore[attr-defined]
|
||||
if blocked_url:
|
||||
@@ -197,17 +181,12 @@ def _browser_cdp_private_guard(
|
||||
method,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Guard probes are best-effort; never break local/custom CDP workflows.
|
||||
logger.debug("browser_cdp: private-page guard probe failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _cdp_call(
|
||||
ws_url: str,
|
||||
method: str,
|
||||
params: Dict[str, Any],
|
||||
target_id: Optional[str],
|
||||
timeout: float,
|
||||
ws_url: str, method: str, params: Dict[str, Any], target_id: Optional[str], timeout: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a single CDP call, optionally attaching to a target first.
|
||||
|
||||
@@ -232,22 +211,18 @@ async def _cdp_call(
|
||||
next_id += 1
|
||||
await ws.send(json.dumps({"id": call_id, **req}))
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while True:
|
||||
while True: # ignore events / out-of-order responses
|
||||
remaining = deadline - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(f"Timed out {what}")
|
||||
msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=remaining))
|
||||
if msg.get("id") == call_id:
|
||||
return msg
|
||||
# Ignore events / out-of-order responses
|
||||
|
||||
session_id: Optional[str] = None
|
||||
if target_id:
|
||||
msg = await _send(
|
||||
{
|
||||
"method": "Target.attachToTarget",
|
||||
"params": {"targetId": target_id, "flatten": True},
|
||||
},
|
||||
{"method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}},
|
||||
f"attaching to target {target_id}",
|
||||
)
|
||||
if "error" in msg:
|
||||
@@ -266,11 +241,7 @@ async def _cdp_call(
|
||||
|
||||
|
||||
def _browser_cdp_via_supervisor(
|
||||
task_id: str,
|
||||
frame_id: str,
|
||||
method: str,
|
||||
params: Optional[Dict[str, Any]],
|
||||
timeout: float,
|
||||
task_id: str, frame_id: str, method: str, params: Optional[Dict[str, Any]], timeout: float,
|
||||
) -> str:
|
||||
"""Route a CDP call through the live supervisor session for an OOPIF frame."""
|
||||
try:
|
||||
@@ -291,11 +262,9 @@ def _browser_cdp_via_supervisor(
|
||||
f"frame_tree with frame_ids you can pass here."
|
||||
)
|
||||
|
||||
snap = supervisor.snapshot()
|
||||
tree = snap.frame_tree
|
||||
tree = supervisor.snapshot().frame_tree
|
||||
frame_info: Optional[Dict[str, Any]] = next(
|
||||
(f for f in [tree.get("top"), *(tree.get("children") or [])]
|
||||
if f and f.get("frame_id") == frame_id),
|
||||
(f for f in [tree.get("top"), *(tree.get("children") or [])] if f and f.get("frame_id") == frame_id),
|
||||
None,
|
||||
)
|
||||
if frame_info is None:
|
||||
@@ -304,7 +273,6 @@ def _browser_cdp_via_supervisor(
|
||||
raw = supervisor._frames.get(frame_id) # type: ignore[attr-defined]
|
||||
if raw is not None:
|
||||
frame_info = raw.to_dict()
|
||||
|
||||
if frame_info is None:
|
||||
return tool_error(
|
||||
f"frame_id {frame_id!r} not found in supervisor state. "
|
||||
@@ -325,10 +293,7 @@ def _browser_cdp_via_supervisor(
|
||||
|
||||
loop = supervisor._loop # type: ignore[attr-defined]
|
||||
if loop is None or not loop.is_running():
|
||||
return tool_error(
|
||||
"CDP supervisor loop is not running. Try reconnecting with "
|
||||
"/browser connect."
|
||||
)
|
||||
return tool_error("CDP supervisor loop is not running. Try reconnecting with /browser connect.")
|
||||
|
||||
try:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
@@ -337,25 +302,20 @@ def _browser_cdp_via_supervisor(
|
||||
loop,
|
||||
)
|
||||
if fut is None:
|
||||
return tool_error(
|
||||
"CDP call via supervisor failed: loop unavailable",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
return tool_error("CDP call via supervisor failed: loop unavailable", cdp_docs=CDP_DOCS_URL)
|
||||
result_msg = fut.result(timeout=timeout + 2)
|
||||
except Exception as exc:
|
||||
return tool_error(
|
||||
f"CDP call via supervisor failed: {type(exc).__name__}: {exc}",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"method": method,
|
||||
"frame_id": frame_id,
|
||||
"session_id": child_sid,
|
||||
"result": result_msg.get("result", {}),
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def browser_cdp(
|
||||
@@ -372,40 +332,26 @@ def browser_cdp(
|
||||
(OOPIF from ``browser_snapshot.frame_tree``) routes through the supervisor's
|
||||
live WebSocket instead — the only reliable way to evaluate inside an iframe
|
||||
on backends where fresh per-call connections hit signed-URL expiry
|
||||
(Browserbase). Returns JSON ``{"success": True, "method", "result"}`` or
|
||||
``{"error": ...}``.
|
||||
(Browserbase). Both paths share the same private-page/SSRF guard. Returns
|
||||
JSON ``{"success": True, "method", "result"}`` or ``{"error": ...}``.
|
||||
"""
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
if frame_id:
|
||||
# Same private-page/SSRF boundary as the stateless path below.
|
||||
blocked = _browser_cdp_private_guard(
|
||||
task_id=effective_task_id,
|
||||
method=method,
|
||||
params=params or {},
|
||||
)
|
||||
blocked = _browser_cdp_private_guard(task_id=effective_task_id, method=method, params=params or {})
|
||||
if blocked:
|
||||
return blocked
|
||||
return _browser_cdp_via_supervisor(
|
||||
task_id=effective_task_id,
|
||||
frame_id=frame_id,
|
||||
method=method,
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
task_id=effective_task_id, frame_id=frame_id, method=method, params=params, timeout=timeout,
|
||||
)
|
||||
|
||||
if not method or not isinstance(method, str):
|
||||
return tool_error(
|
||||
"'method' is required (e.g. 'Target.getTargets')",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
|
||||
return tool_error("'method' is required (e.g. 'Target.getTargets')", cdp_docs=CDP_DOCS_URL)
|
||||
if not _WS_AVAILABLE:
|
||||
return tool_error(
|
||||
"The 'websockets' Python package is required but not installed. "
|
||||
"Install it with: pip install websockets"
|
||||
)
|
||||
|
||||
endpoint = _resolve_cdp_endpoint()
|
||||
if not endpoint:
|
||||
return tool_error(
|
||||
@@ -415,7 +361,6 @@ def browser_cdp(
|
||||
"and does not expose CDP.",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
|
||||
if not endpoint.startswith(("ws://", "wss://")):
|
||||
return tool_error(
|
||||
f"CDP endpoint is not a WebSocket URL: {endpoint!r}. "
|
||||
@@ -423,18 +368,11 @@ def browser_cdp(
|
||||
"resolver should have rewritten this. Check that a Chromium-family "
|
||||
"browser is actually listening on the debug port."
|
||||
)
|
||||
|
||||
call_params: Dict[str, Any] = params or {}
|
||||
if not isinstance(call_params, dict):
|
||||
return tool_error(
|
||||
f"'params' must be an object/dict, got {type(call_params).__name__}"
|
||||
)
|
||||
return tool_error(f"'params' must be an object/dict, got {type(call_params).__name__}")
|
||||
|
||||
blocked = _browser_cdp_private_guard(
|
||||
task_id=effective_task_id,
|
||||
method=method,
|
||||
params=call_params,
|
||||
)
|
||||
blocked = _browser_cdp_private_guard(task_id=effective_task_id, method=method, params=call_params)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
@@ -445,14 +383,9 @@ def browser_cdp(
|
||||
safe_timeout = max(1.0, min(safe_timeout, 300.0))
|
||||
|
||||
try:
|
||||
result = _run_async(
|
||||
_cdp_call(endpoint, method, call_params, target_id, safe_timeout)
|
||||
)
|
||||
result = _run_async(_cdp_call(endpoint, method, call_params, target_id, safe_timeout))
|
||||
except asyncio.TimeoutError as exc:
|
||||
return tool_error(
|
||||
f"CDP call timed out after {safe_timeout}s: {exc}",
|
||||
method=method,
|
||||
)
|
||||
return tool_error(f"CDP call timed out after {safe_timeout}s: {exc}", method=method)
|
||||
except (TimeoutError, RuntimeError) as exc:
|
||||
return tool_error(str(exc), method=method)
|
||||
except WebSocketException as exc:
|
||||
@@ -463,10 +396,7 @@ def browser_cdp(
|
||||
)
|
||||
except Exception as exc: # pragma: no cover — unexpected
|
||||
logger.exception("browser_cdp unexpected error")
|
||||
return tool_error(
|
||||
f"Unexpected error: {type(exc).__name__}: {exc}",
|
||||
method=method,
|
||||
)
|
||||
return tool_error(f"Unexpected error: {type(exc).__name__}: {exc}", method=method)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"success": True,
|
||||
@@ -586,6 +516,8 @@ def _browser_cdp_check() -> bool:
|
||||
Camofox (REST-only), default local agent-browser (hidden CDP port) and cloud
|
||||
providers whose per-session ``cdp_url`` isn't surfaced are gated out. Thin
|
||||
wrapper so ``registry.register`` stays a top-level statement (AST scan).
|
||||
Raw (no-I/O) gate: check_fns run at every startup; resolving the endpoint
|
||||
over HTTP here would block launch on a stale endpoint.
|
||||
"""
|
||||
try:
|
||||
from tools.browser_tool import ( # type: ignore[import-not-found]
|
||||
@@ -595,11 +527,7 @@ def _browser_cdp_check() -> bool:
|
||||
except ImportError as exc: # pragma: no cover — defensive
|
||||
logger.debug("browser_cdp check: browser_tool import failed: %s", exc)
|
||||
return False
|
||||
if not check_browser_requirements():
|
||||
return False
|
||||
# Raw (no-I/O) gate: check_fns run at every startup; resolving the
|
||||
# endpoint over HTTP here would block launch on a stale endpoint.
|
||||
return bool(_get_cdp_override_raw())
|
||||
return bool(check_browser_requirements() and _get_cdp_override_raw())
|
||||
|
||||
|
||||
registry.register(
|
||||
|
||||
@@ -80,31 +80,18 @@ def browser_dialog(
|
||||
"""Respond to a pending dialog on the active task's CDP supervisor."""
|
||||
supervisor = SUPERVISOR_REGISTRY.get(task_id or "default")
|
||||
if supervisor is None:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"No CDP supervisor is attached to this task. Either the "
|
||||
"browser backend doesn't expose CDP (Camofox, default "
|
||||
"Playwright) or no browser session has been started yet. "
|
||||
"Call browser_navigate or /browser connect first."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
result = supervisor.respond_to_dialog(
|
||||
action=action,
|
||||
prompt_text=prompt_text,
|
||||
dialog_id=dialog_id,
|
||||
)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": (
|
||||
"No CDP supervisor is attached to this task. Either the "
|
||||
"browser backend doesn't expose CDP (Camofox, default "
|
||||
"Playwright) or no browser session has been started yet. "
|
||||
"Call browser_navigate or /browser connect first."
|
||||
),
|
||||
})
|
||||
result = supervisor.respond_to_dialog(action=action, prompt_text=prompt_text, dialog_id=dialog_id)
|
||||
if result.get("ok"):
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"action": action,
|
||||
"dialog": result.get("dialog", {}),
|
||||
}
|
||||
)
|
||||
return json.dumps({"success": True, "action": action, "dialog": result.get("dialog", {})})
|
||||
return json.dumps({"success": False, "error": result.get("error", "unknown error")})
|
||||
|
||||
|
||||
|
||||
@@ -51,10 +51,7 @@ def extension_controller_available(action: str) -> bool:
|
||||
consults the process-local broker directly and fails closed on any gap.
|
||||
"""
|
||||
try:
|
||||
from gateway.browser_control_broker import (
|
||||
browser_control_enabled,
|
||||
get_browser_control_broker,
|
||||
)
|
||||
from gateway.browser_control_broker import browser_control_enabled, get_browser_control_broker
|
||||
|
||||
if not browser_control_enabled():
|
||||
return False
|
||||
@@ -63,17 +60,11 @@ def extension_controller_available(action: str) -> bool:
|
||||
return False
|
||||
broker = get_browser_control_broker()
|
||||
scope = broker.scope_for_session(
|
||||
session_id=session_id,
|
||||
principal_id=principal_id,
|
||||
transport_family=transport_family,
|
||||
session_id=session_id, principal_id=principal_id, transport_family=transport_family,
|
||||
)
|
||||
return scope is not None and broker.select(scope, action) is not None
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"browser extension availability check failed for %s",
|
||||
action,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.debug("browser extension availability check failed for %s", action, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -97,17 +88,11 @@ def route_browser_tool(
|
||||
called exactly once when the feature is off or no server-bound identity
|
||||
exists; once a controller is selected its result/exception is final.
|
||||
"""
|
||||
if not enabled:
|
||||
return fallback()
|
||||
|
||||
if not str(principal_id or "").strip() or not str(transport_family or "").strip():
|
||||
if not enabled or not str(principal_id or "").strip() or not str(transport_family or "").strip():
|
||||
return fallback()
|
||||
|
||||
identity = dict(
|
||||
session_id=session_id,
|
||||
task_id=task_id,
|
||||
principal_id=principal_id,
|
||||
transport_family=transport_family,
|
||||
session_id=session_id, task_id=task_id, principal_id=principal_id, transport_family=transport_family,
|
||||
)
|
||||
scope = broker.scope_for_session(**identity)
|
||||
if scope is None:
|
||||
@@ -117,25 +102,16 @@ def route_browser_tool(
|
||||
lane_bound = getattr(broker, "lane_registered", None)
|
||||
if callable(lane_bound) and not lane_bound(**identity):
|
||||
return fallback()
|
||||
raise _controller_unavailable(
|
||||
f"bound browser controller unavailable for {action}"
|
||||
)
|
||||
raise _controller_unavailable(f"bound browser controller unavailable for {action}")
|
||||
|
||||
controller = broker.select(scope, action)
|
||||
if controller is None:
|
||||
raise _controller_unavailable(
|
||||
f"bound browser controller cannot execute {action}"
|
||||
)
|
||||
if broker.select(scope, action) is None:
|
||||
raise _controller_unavailable(f"bound browser controller cannot execute {action}")
|
||||
|
||||
# Controller is authoritative: never retry the legacy backend. Registry
|
||||
# handlers must return a string; keep string results byte-identical and
|
||||
# serialize decoded JSON values at this boundary.
|
||||
result = broker.dispatch(
|
||||
scope, action=action, arguments=args, tool_call_id=tool_call_id
|
||||
)
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
result = broker.dispatch(scope, action=action, arguments=args, tool_call_id=tool_call_id)
|
||||
return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
|
||||
|
||||
|
||||
def current_tool_call_id() -> str:
|
||||
@@ -164,23 +140,13 @@ def routed_browser_handler(
|
||||
Feature off (or gateway unimportable) ⇒ the legacy handler runs unchanged.
|
||||
"""
|
||||
try:
|
||||
from gateway.browser_control_broker import (
|
||||
browser_control_enabled,
|
||||
get_browser_control_broker,
|
||||
)
|
||||
from gateway.browser_control_broker import browser_control_enabled, get_browser_control_broker
|
||||
except Exception as exc: # pragma: no cover - defensive, gateway always present
|
||||
logger.debug(
|
||||
"browser extension router unavailable (%s); using legacy backend",
|
||||
exc,
|
||||
)
|
||||
logger.debug("browser extension router unavailable (%s); using legacy backend", exc)
|
||||
return fallback()
|
||||
|
||||
if not browser_control_enabled():
|
||||
return fallback()
|
||||
|
||||
if tool_call_id is None:
|
||||
tool_call_id = current_tool_call_id()
|
||||
|
||||
try:
|
||||
env_session, env_principal, env_transport = _bound_identity()
|
||||
except Exception:
|
||||
@@ -196,5 +162,5 @@ def routed_browser_handler(
|
||||
task_id=task_id,
|
||||
principal_id=principal_id or env_principal,
|
||||
transport_family=transport_family or env_transport,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_id=current_tool_call_id() if tool_call_id is None else tool_call_id,
|
||||
)
|
||||
|
||||
+55
-132
@@ -57,12 +57,9 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ring buffer of recent console-level events.
|
||||
CONSOLE_HISTORY_MAX = 50
|
||||
|
||||
|
||||
def _redact_cdp_error_text(exc: object) -> str:
|
||||
"""Redact CDP endpoint credentials from an exception's string form.
|
||||
"""Redact CDP endpoint credentials from an exception's (or URL's) string form.
|
||||
|
||||
``websockets`` bakes the raw target URL (``?token=`` / ``user:pass@``) into
|
||||
its exception messages. Every egress point that turns such an exception into
|
||||
@@ -91,17 +88,8 @@ def _schedule(coro, loop, *, timeout: float):
|
||||
return fut.result(timeout=timeout)
|
||||
|
||||
|
||||
# ── Data model ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleEvent:
|
||||
"""Ring buffer entry for console + exception traffic."""
|
||||
|
||||
ts: float
|
||||
level: str # "log" | "error" | "warning" | "exception"
|
||||
text: str
|
||||
url: Optional[str] = None
|
||||
def _err(exc: BaseException) -> Dict[str, Any]:
|
||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -111,7 +99,6 @@ class SupervisorSnapshot:
|
||||
pending_dialogs: Tuple[PendingDialog, ...]
|
||||
recent_dialogs: Tuple[DialogRecord, ...]
|
||||
frame_tree: Dict[str, Any]
|
||||
console_errors: Tuple[ConsoleEvent, ...]
|
||||
active: bool # False if supervisor is detached/stopped
|
||||
cdp_url: str
|
||||
task_id: str
|
||||
@@ -163,7 +150,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
self._pending_dialogs: Dict[str, PendingDialog] = {}
|
||||
self._recent_dialogs: List[DialogRecord] = []
|
||||
self._frames: Dict[str, FrameInfo] = {}
|
||||
self._console_events: List[ConsoleEvent] = []
|
||||
self._active = False
|
||||
|
||||
# Supervisor loop machinery — populated in start().
|
||||
@@ -197,21 +183,14 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
self._start_error = None
|
||||
self._stop_requested = False
|
||||
self._thread = threading.Thread(
|
||||
target=self._thread_main,
|
||||
name=f"cdp-supervisor-{self.task_id}",
|
||||
daemon=True,
|
||||
target=self._thread_main, name=f"cdp-supervisor-{self.task_id}", daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
if not self._ready_event.wait(timeout=timeout):
|
||||
self.stop()
|
||||
try:
|
||||
from agent.redact import redact_cdp_url
|
||||
_safe_url = redact_cdp_url(self.cdp_url)
|
||||
except Exception:
|
||||
_safe_url = "<cdp_url redacted>"
|
||||
raise TimeoutError(
|
||||
f"CDP supervisor did not attach within {timeout}s "
|
||||
f"(cdp_url={_safe_url[:80]}...)"
|
||||
f"(cdp_url={_redact_cdp_error_text(self.cdp_url)[:80]}...)"
|
||||
)
|
||||
if self._start_error is not None:
|
||||
err = self._start_error
|
||||
@@ -247,7 +226,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
pending_dialogs=tuple(self._pending_dialogs.values()),
|
||||
recent_dialogs=tuple(self._recent_dialogs[-RECENT_DIALOGS_MAX:]),
|
||||
frame_tree=self._build_frame_tree_locked(),
|
||||
console_errors=tuple(self._console_events[-CONSOLE_HISTORY_MAX:]),
|
||||
active=self._active,
|
||||
cdp_url=self.cdp_url,
|
||||
task_id=self.task_id,
|
||||
@@ -309,7 +287,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
except _LoopUnavailable as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
return _err(e)
|
||||
return {"ok": True, "dialog": dialog.to_dict()}
|
||||
|
||||
def evaluate_runtime(
|
||||
@@ -362,13 +340,12 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
# CDP's recursion guard with the protocol-level error ``Object
|
||||
# reference chain is too long``. Retry once with returnByValue=False
|
||||
# so Chrome returns the description string instead of failing.
|
||||
if return_by_value and "reference chain is too long" in str(exc).lower():
|
||||
try:
|
||||
response = _run_eval(False)
|
||||
except Exception as exc2:
|
||||
return {"ok": False, "error": f"{type(exc2).__name__}: {exc2}"}
|
||||
else:
|
||||
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
||||
if not (return_by_value and "reference chain is too long" in str(exc).lower()):
|
||||
return _err(exc)
|
||||
try:
|
||||
response = _run_eval(False)
|
||||
except Exception as exc2:
|
||||
return _err(exc2)
|
||||
|
||||
# Response: {"result": {"result": {"type", "value", ...}, "exceptionDetails"?}}
|
||||
result_payload = response.get("result", {}) if isinstance(response, dict) else {}
|
||||
@@ -382,7 +359,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
|
||||
result_obj = result_payload.get("result", {})
|
||||
result_type = result_obj.get("type", "undefined")
|
||||
|
||||
if "value" in result_obj:
|
||||
value = result_obj["value"]
|
||||
elif result_type == "undefined":
|
||||
@@ -391,7 +367,6 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
# Non-serializable (functions, DOM nodes…) — give the model the
|
||||
# browser's description so it gets *something*.
|
||||
value = result_obj.get("description") or result_obj.get("unserializableValue")
|
||||
|
||||
return {"ok": True, "result": value, "result_type": result_type}
|
||||
|
||||
# ── Supervisor loop internals ────────────────────────────────────────────
|
||||
@@ -404,13 +379,10 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(self._run())
|
||||
except BaseException as e: # noqa: BLE001 — propagate via _start_error
|
||||
if not self._ready_event.is_set():
|
||||
self._start_error = e
|
||||
self._ready_event.set()
|
||||
else:
|
||||
if not self._fail_start(e):
|
||||
logger.warning("CDP supervisor %s crashed: %s", self.task_id, e)
|
||||
finally:
|
||||
# Flush remaining tasks before closing the loop to avoid
|
||||
# Cancel + flush remaining tasks before closing the loop to avoid
|
||||
# "Task was destroyed but it is pending" warnings.
|
||||
try:
|
||||
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
|
||||
@@ -418,19 +390,23 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
t.cancel()
|
||||
if pending:
|
||||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
with self._state_lock:
|
||||
self._active = False
|
||||
|
||||
def _fail_start(self, e: BaseException) -> bool:
|
||||
"""Propagate ``e`` to ``start()`` if we never got ready; True if it was consumed."""
|
||||
if self._ready_event.is_set():
|
||||
return False
|
||||
self._start_error = e
|
||||
self._ready_event.set()
|
||||
return True
|
||||
|
||||
async def _close_ws(self) -> None:
|
||||
"""Detach and close the current WebSocket, swallowing close errors."""
|
||||
ws = self._ws
|
||||
self._ws = None
|
||||
ws, self._ws = self._ws, None
|
||||
if ws is not None:
|
||||
try:
|
||||
await ws.close()
|
||||
@@ -442,7 +418,8 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
|
||||
Browserbase tears down the CDP socket every time a short-lived client
|
||||
(e.g. agent-browser's per-command CDP client) disconnects, so on drop we
|
||||
reset per-session ids, re-attach, and keep going.
|
||||
reset per-session ids, re-attach, and keep going. A failure before the
|
||||
first successful attach is fatal for ``start()``.
|
||||
"""
|
||||
attempt = 0
|
||||
last_success_at = 0.0
|
||||
@@ -451,15 +428,11 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
while not self._stop_requested:
|
||||
try:
|
||||
self._ws = await asyncio.wait_for(
|
||||
websockets.connect(self.cdp_url, max_size=50 * 1024 * 1024),
|
||||
timeout=10.0,
|
||||
websockets.connect(self.cdp_url, max_size=50 * 1024 * 1024), timeout=10.0,
|
||||
)
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
if not self._ready_event.is_set():
|
||||
# Never connected once — fatal for start().
|
||||
self._start_error = e
|
||||
self._ready_event.set()
|
||||
if self._fail_start(e):
|
||||
return
|
||||
logger.warning(
|
||||
"CDP supervisor %s: connect failed (attempt %s): %s",
|
||||
@@ -481,20 +454,14 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
self._active = True
|
||||
last_success_at = time.time()
|
||||
backoff = 0.5 # reset after a successful attach
|
||||
if not self._ready_event.is_set():
|
||||
self._ready_event.set()
|
||||
self._ready_event.set()
|
||||
await reader_task
|
||||
except BaseException as e:
|
||||
if not self._ready_event.is_set():
|
||||
# Never got to ready — propagate to start().
|
||||
self._start_error = e
|
||||
self._ready_event.set()
|
||||
if self._fail_start(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"CDP supervisor %s: session dropped after %.1fs: %s",
|
||||
self.task_id,
|
||||
time.time() - last_success_at,
|
||||
_redact_cdp_error_text(e),
|
||||
self.task_id, time.time() - last_success_at, _redact_cdp_error_text(e),
|
||||
)
|
||||
finally:
|
||||
with self._state_lock:
|
||||
@@ -505,22 +472,19 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
await reader_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
for handle in list(self._dialog_watchdogs.values()):
|
||||
for handle in self._dialog_watchdogs.values():
|
||||
handle.cancel()
|
||||
self._dialog_watchdogs.clear()
|
||||
await self._close_ws()
|
||||
|
||||
if self._stop_requested:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff,
|
||||
)
|
||||
logger.debug("CDP supervisor %s: reconnecting in %.1fs...", self.task_id, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 10.0)
|
||||
|
||||
async def _attach_initial_page(self) -> None:
|
||||
"""Find a page target, attach flattened session, enable domains, install dialog bridge."""
|
||||
"""Find (or create) a page target, attach flattened, enable domains, install dialog bridge."""
|
||||
resp = await self._cdp("Target.getTargets")
|
||||
targets = resp.get("result", {}).get("targetInfos", [])
|
||||
page_target = next((t for t in targets if t.get("type") == "page"), None)
|
||||
@@ -529,11 +493,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
target_id = created["result"]["targetId"]
|
||||
else:
|
||||
target_id = page_target["targetId"]
|
||||
|
||||
attach = await self._cdp(
|
||||
"Target.attachToTarget",
|
||||
{"targetId": target_id, "flatten": True},
|
||||
)
|
||||
attach = await self._cdp("Target.attachToTarget", {"targetId": target_id, "flatten": True})
|
||||
self._page_session_id = attach["result"]["sessionId"]
|
||||
await self._enable_page_domains(self._page_session_id, timeout=10.0)
|
||||
await self._install_dialog_bridge(self._page_session_id)
|
||||
@@ -565,7 +525,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
self._pending_calls.pop(call_id, None)
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
"""Continuously dispatch incoming CDP frames."""
|
||||
"""Continuously dispatch incoming CDP frames (responses → futures, events → handlers)."""
|
||||
assert self._ws is not None
|
||||
try:
|
||||
async for raw in self._ws:
|
||||
@@ -580,58 +540,23 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
fut = self._pending_calls.pop(msg["id"], None)
|
||||
if fut is not None and not fut.done():
|
||||
if "error" in msg:
|
||||
fut.set_exception(
|
||||
RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}")
|
||||
)
|
||||
fut.set_exception(RuntimeError(f"CDP error on id={msg['id']}: {msg['error']}"))
|
||||
else:
|
||||
fut.set_result(msg)
|
||||
elif "method" in msg:
|
||||
await self._on_event(msg["method"], msg.get("params", {}), msg.get("sessionId"))
|
||||
handler = self._EVENT_HANDLERS.get(msg["method"])
|
||||
if handler is not None:
|
||||
result = handler(self, msg.get("params", {}), msg.get("sessionId"))
|
||||
if result is not None:
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.debug("CDP read loop exited: %s", e)
|
||||
|
||||
# ── Event dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
async def _on_event(
|
||||
self, method: str, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
handler = self._EVENT_HANDLERS.get(method)
|
||||
if handler is None:
|
||||
return
|
||||
result = handler(self, params, session_id)
|
||||
if result is not None:
|
||||
await result
|
||||
|
||||
# ── Console / exception ring buffer ─────────────────────────────────────
|
||||
|
||||
def _on_console(self, params: Dict[str, Any], *, level_from: str) -> None:
|
||||
if level_from == "exception":
|
||||
details = params.get("exceptionDetails") or {}
|
||||
text = str(details.get("text") or "")
|
||||
url = details.get("url")
|
||||
event = ConsoleEvent(ts=time.time(), level="exception", text=text, url=url)
|
||||
else:
|
||||
raw_level = str(params.get("type") or "log")
|
||||
level = "error" if raw_level in {"error", "assert"} else (
|
||||
"warning" if raw_level == "warning" else "log"
|
||||
)
|
||||
args = params.get("args") or []
|
||||
parts: List[str] = []
|
||||
for a in args[:4]:
|
||||
if isinstance(a, dict):
|
||||
parts.append(str(a.get("value") or a.get("description") or ""))
|
||||
event = ConsoleEvent(ts=time.time(), level=level, text=" ".join(parts))
|
||||
with self._state_lock:
|
||||
self._console_events.append(event)
|
||||
self._console_events = _trim_ring(self._console_events, CONSOLE_HISTORY_MAX)
|
||||
|
||||
# CDP event → handler(self, params, session_id). Async handlers return an
|
||||
# awaitable that ``_on_event`` awaits; sync handlers return None.
|
||||
# awaitable that ``_read_loop`` awaits; sync handlers return None.
|
||||
_EVENT_HANDLERS: Dict[str, Callable[..., Any]] = {
|
||||
**DialogSupervisionMixin.EVENT_HANDLERS,
|
||||
**FrameTrackingMixin.EVENT_HANDLERS,
|
||||
"Runtime.consoleAPICalled": lambda self, p, _sid: self._on_console(p, level_from="api"),
|
||||
"Runtime.exceptionThrown": lambda self, p, _sid: self._on_console(p, level_from="exception"),
|
||||
}
|
||||
|
||||
|
||||
@@ -664,27 +589,26 @@ class _SupervisorRegistry:
|
||||
) -> CDPSupervisor:
|
||||
"""Idempotently ensure a supervisor is running for ``(task_id, cdp_url)``.
|
||||
|
||||
An existing supervisor bound to a different ``cdp_url`` (or unhealthy)
|
||||
is stopped and replaced.
|
||||
An existing supervisor bound to a different ``cdp_url`` (or unhealthy:
|
||||
dead thread / stopped loop) is stopped and replaced.
|
||||
"""
|
||||
with self._lock:
|
||||
existing = self._by_task.get(task_id)
|
||||
if existing is not None:
|
||||
if existing.cdp_url == cdp_url:
|
||||
thread_ok = existing._thread is not None and existing._thread.is_alive()
|
||||
loop_ok = existing._loop is not None and existing._loop.is_running()
|
||||
if thread_ok and loop_ok:
|
||||
return existing
|
||||
# URL changed or unhealthy — tear down, fall through to re-create.
|
||||
thread, loop = existing._thread, existing._loop
|
||||
if (
|
||||
existing.cdp_url == cdp_url
|
||||
and thread is not None and thread.is_alive()
|
||||
and loop is not None and loop.is_running()
|
||||
):
|
||||
return existing
|
||||
self._by_task.pop(task_id, None)
|
||||
if existing is not None:
|
||||
existing.stop()
|
||||
|
||||
supervisor = CDPSupervisor(
|
||||
task_id=task_id,
|
||||
cdp_url=cdp_url,
|
||||
dialog_policy=dialog_policy,
|
||||
dialog_timeout_s=dialog_timeout_s,
|
||||
task_id=task_id, cdp_url=cdp_url,
|
||||
dialog_policy=dialog_policy, dialog_timeout_s=dialog_timeout_s,
|
||||
)
|
||||
supervisor.start(timeout=start_timeout)
|
||||
with self._lock:
|
||||
@@ -706,9 +630,9 @@ class _SupervisorRegistry:
|
||||
def stop_all(self) -> None:
|
||||
"""Stop every running supervisor. For shutdown / test teardown."""
|
||||
with self._lock:
|
||||
items = list(self._by_task.items())
|
||||
items = list(self._by_task.values())
|
||||
self._by_task.clear()
|
||||
for _, supervisor in items:
|
||||
for supervisor in items:
|
||||
supervisor.stop()
|
||||
|
||||
|
||||
@@ -717,7 +641,6 @@ SUPERVISOR_REGISTRY = _SupervisorRegistry()
|
||||
|
||||
__all__ = [
|
||||
"CDPSupervisor",
|
||||
"ConsoleEvent",
|
||||
"DEFAULT_DIALOG_POLICY",
|
||||
"DEFAULT_DIALOG_TIMEOUT_S",
|
||||
"DIALOG_POLICY_AUTO_ACCEPT",
|
||||
|
||||
@@ -22,7 +22,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Coroutine, Dict, Optional
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Logger-name parity with the origin module (records must look unchanged).
|
||||
@@ -46,11 +46,9 @@ def _trim_ring(events: list, keep: int) -> list:
|
||||
DIALOG_POLICY_MUST_RESPOND = "must_respond"
|
||||
DIALOG_POLICY_AUTO_DISMISS = "auto_dismiss"
|
||||
DIALOG_POLICY_AUTO_ACCEPT = "auto_accept"
|
||||
|
||||
_VALID_POLICIES = frozenset(
|
||||
{DIALOG_POLICY_MUST_RESPOND, DIALOG_POLICY_AUTO_DISMISS, DIALOG_POLICY_AUTO_ACCEPT}
|
||||
)
|
||||
|
||||
DEFAULT_DIALOG_POLICY = DIALOG_POLICY_MUST_RESPOND
|
||||
DEFAULT_DIALOG_TIMEOUT_S = 300.0
|
||||
|
||||
@@ -64,7 +62,12 @@ RECENT_DIALOGS_MAX = 20
|
||||
DIALOG_BRIDGE_HOST = "hermes-dialog-bridge.invalid"
|
||||
DIALOG_BRIDGE_URL_PATTERN = f"http://{DIALOG_BRIDGE_HOST}/*"
|
||||
|
||||
# Injected into every frame via Page.addScriptToEvaluateOnNewDocument.
|
||||
# Injected into every frame via Page.addScriptToEvaluateOnNewDocument. Uses a
|
||||
# sync GET with query params so the Fetch interceptor never parses a body; if
|
||||
# the bridge is unreachable it returns null so the page still sees *some*
|
||||
# behavior (the backend auto-dismisses). onbeforeunload is left native — it
|
||||
# can't be prompted synchronously without racing navigation; the native-dialog
|
||||
# fallback path still surfaces it in recent_dialogs.
|
||||
_DIALOG_BRIDGE_SCRIPT = r"""
|
||||
(() => {
|
||||
if (window.__hermesDialogBridgeInstalled) return;
|
||||
@@ -73,8 +76,6 @@ _DIALOG_BRIDGE_SCRIPT = r"""
|
||||
function ask(kind, message, defaultPrompt) {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Use GET with query params so we don't need to worry about request
|
||||
// body encoding in the Fetch interceptor.
|
||||
const params = new URLSearchParams({
|
||||
kind: String(kind || ""),
|
||||
message: String(message == null ? "" : message),
|
||||
@@ -83,9 +84,8 @@ _DIALOG_BRIDGE_SCRIPT = r"""
|
||||
xhr.open("GET", ENDPOINT + "?" + params.toString(), false); // sync
|
||||
xhr.send(null);
|
||||
if (xhr.status !== 200) return null;
|
||||
const body = xhr.responseText || "";
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch (e) { return null; }
|
||||
try { parsed = JSON.parse(xhr.responseText || ""); } catch (e) { return null; }
|
||||
if (kind === "alert") return undefined;
|
||||
if (kind === "confirm") return Boolean(parsed && parsed.accept);
|
||||
if (kind === "prompt") {
|
||||
@@ -94,14 +94,9 @@ _DIALOG_BRIDGE_SCRIPT = r"""
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
// If the bridge is unreachable, fall back to the native call so the
|
||||
// page still sees *some* behavior (the backend will auto-dismiss).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const realAlert = window.alert;
|
||||
const realConfirm = window.confirm;
|
||||
const realPrompt = window.prompt;
|
||||
window.alert = function(message) { ask("alert", message, ""); };
|
||||
window.confirm = function(message) {
|
||||
const r = ask("confirm", message, "");
|
||||
@@ -111,10 +106,6 @@ _DIALOG_BRIDGE_SCRIPT = r"""
|
||||
const r = ask("prompt", message, def == null ? "" : def);
|
||||
return r === null ? null : String(r);
|
||||
};
|
||||
// onbeforeunload — we can't really synchronously prompt the user from this
|
||||
// event without racing navigation. Leave native behavior for now; the
|
||||
// supervisor's native-dialog fallback path still surfaces them in
|
||||
// recent_dialogs.
|
||||
})();
|
||||
"""
|
||||
|
||||
@@ -178,6 +169,14 @@ class DialogRecord:
|
||||
class DialogSupervisionMixin:
|
||||
"""Dialog event handling for ``CDPSupervisor`` (all methods run on its loop)."""
|
||||
|
||||
async def _cdp_quiet(self, method: str, params: Dict[str, Any], *, session_id: Optional[str],
|
||||
timeout: float, what: str) -> None:
|
||||
"""Best-effort CDP call: failures are logged at debug and swallowed."""
|
||||
try:
|
||||
await self._cdp(method, params, session_id=session_id, timeout=timeout)
|
||||
except Exception as e:
|
||||
logger.debug("%s failed (%s): %s", method, what, e)
|
||||
|
||||
async def _install_dialog_bridge(self, session_id: str) -> None:
|
||||
"""Install the dialog-bridge init script + Fetch interceptor on a session.
|
||||
|
||||
@@ -185,110 +184,65 @@ class DialogSupervisionMixin:
|
||||
scoped to the bridge URL catches the XHRs, which surface as pending
|
||||
dialogs and are fulfilled when the agent responds. Idempotent at the CDP
|
||||
level (Chromium de-dupes identical add-script calls; Fetch.enable
|
||||
replaces prior patterns).
|
||||
replaces prior patterns). The final Runtime.evaluate injects into the
|
||||
already-loaded document so existing pages pick up the override on reconnect.
|
||||
"""
|
||||
try:
|
||||
await self._cdp(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
{"source": _DIALOG_BRIDGE_SCRIPT, "runImmediately": True},
|
||||
session_id=session_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"dialog bridge: addScriptToEvaluateOnNewDocument failed on sid=%s: %s",
|
||||
(session_id or "")[:16], e,
|
||||
)
|
||||
try:
|
||||
await self._cdp(
|
||||
"Fetch.enable",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"urlPattern": DIALOG_BRIDGE_URL_PATTERN,
|
||||
"requestStage": "Request",
|
||||
}
|
||||
],
|
||||
"handleAuthRequests": False,
|
||||
},
|
||||
session_id=session_id,
|
||||
timeout=5.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"dialog bridge: Fetch.enable failed on sid=%s: %s",
|
||||
(session_id or "")[:16], e,
|
||||
)
|
||||
# Best-effort inject into the already-loaded document so existing pages
|
||||
# pick up the override on reconnect.
|
||||
try:
|
||||
await self._cdp(
|
||||
"Runtime.evaluate",
|
||||
{"expression": _DIALOG_BRIDGE_SCRIPT, "returnByValue": True},
|
||||
session_id=session_id,
|
||||
timeout=3.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
sid = (session_id or "")[:16]
|
||||
await self._cdp_quiet(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
{"source": _DIALOG_BRIDGE_SCRIPT, "runImmediately": True},
|
||||
session_id=session_id, timeout=5.0, what=f"dialog bridge sid={sid}",
|
||||
)
|
||||
await self._cdp_quiet(
|
||||
"Fetch.enable",
|
||||
{"patterns": [{"urlPattern": DIALOG_BRIDGE_URL_PATTERN, "requestStage": "Request"}],
|
||||
"handleAuthRequests": False},
|
||||
session_id=session_id, timeout=5.0, what=f"dialog bridge sid={sid}",
|
||||
)
|
||||
await self._cdp_quiet(
|
||||
"Runtime.evaluate",
|
||||
{"expression": _DIALOG_BRIDGE_SCRIPT, "returnByValue": True},
|
||||
session_id=session_id, timeout=3.0, what=f"dialog bridge inject sid={sid}",
|
||||
)
|
||||
|
||||
# ── Capture ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _on_dialog_opening(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
dialog = self._new_dialog(
|
||||
async def _on_dialog_opening(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
|
||||
self._admit_dialog(self._new_dialog(
|
||||
type=str(params.get("type") or ""),
|
||||
message=str(params.get("message") or ""),
|
||||
default_prompt=str(params.get("defaultPrompt") or ""),
|
||||
session_id=session_id,
|
||||
frame_id=params.get("frameId"),
|
||||
)
|
||||
self._admit_dialog(dialog, self._auto_handle_dialog)
|
||||
))
|
||||
|
||||
async def _on_fetch_paused(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
async def _on_fetch_paused(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
|
||||
"""Bridge XHR captured mid-flight — materialize as a pending dialog.
|
||||
|
||||
The page's JS thread is blocked on the XHR until we Fetch.fulfillRequest
|
||||
(from ``respond_to_dialog`` or the watchdog).
|
||||
(from ``respond_to_dialog`` or the watchdog). Requests for other hosts
|
||||
are forwarded unchanged so the page sees its own request.
|
||||
"""
|
||||
url = str(params.get("request", {}).get("url") or "")
|
||||
request_id = params.get("requestId")
|
||||
if not request_id:
|
||||
return
|
||||
if DIALOG_BRIDGE_HOST not in url:
|
||||
# Not ours — forward unchanged so the page sees its own request.
|
||||
try:
|
||||
await self._cdp(
|
||||
"Fetch.continueRequest", {"requestId": request_id},
|
||||
session_id=session_id, timeout=3.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await self._cdp_quiet("Fetch.continueRequest", {"requestId": request_id},
|
||||
session_id=session_id, timeout=3.0, what="passthrough")
|
||||
return
|
||||
|
||||
q = parse_qs(urlparse(url).query)
|
||||
dialog = self._new_dialog(
|
||||
self._admit_dialog(self._new_dialog(
|
||||
type=q.get("kind", [""])[0] or "alert",
|
||||
message=q.get("message", [""])[0],
|
||||
default_prompt=q.get("default_prompt", [""])[0],
|
||||
session_id=session_id,
|
||||
frame_id=params.get("frameId"),
|
||||
bridge_request_id=str(request_id),
|
||||
)
|
||||
self._admit_dialog(dialog, self._fulfill_bridge_request)
|
||||
))
|
||||
|
||||
def _new_dialog(
|
||||
self,
|
||||
*,
|
||||
type: str,
|
||||
message: str,
|
||||
default_prompt: str,
|
||||
session_id: Optional[str],
|
||||
frame_id: Optional[str],
|
||||
bridge_request_id: Optional[str] = None,
|
||||
) -> PendingDialog:
|
||||
def _new_dialog(self, *, type: str, message: str, default_prompt: str, session_id: Optional[str],
|
||||
frame_id: Optional[str], bridge_request_id: Optional[str] = None) -> PendingDialog:
|
||||
self._dialog_seq += 1
|
||||
return PendingDialog(
|
||||
id=f"d-{self._dialog_seq}",
|
||||
@@ -301,12 +255,8 @@ class DialogSupervisionMixin:
|
||||
bridge_request_id=bridge_request_id,
|
||||
)
|
||||
|
||||
def _admit_dialog(
|
||||
self,
|
||||
dialog: PendingDialog,
|
||||
responder: Callable[..., Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
"""Apply the dialog policy: auto-respond via ``responder`` or queue + arm watchdog.
|
||||
def _admit_dialog(self, dialog: PendingDialog) -> None:
|
||||
"""Apply the dialog policy: auto-respond, or queue + arm the watchdog.
|
||||
|
||||
Auto policies archive FIRST (tagged ``auto_policy``) so the ``closed``
|
||||
event that follows our own response isn't re-archived as ``remote``.
|
||||
@@ -316,61 +266,33 @@ class DialogSupervisionMixin:
|
||||
DIALOG_POLICY_AUTO_ACCEPT: (True, dialog.default_prompt),
|
||||
}.get(self.dialog_policy)
|
||||
if auto is not None:
|
||||
accept, prompt_text = auto
|
||||
with self._state_lock:
|
||||
self._archive_dialog_locked(dialog, "auto_policy")
|
||||
asyncio.create_task(responder(dialog, accept=accept, prompt_text=prompt_text))
|
||||
asyncio.create_task(self._respond_quiet(dialog, accept=auto[0], prompt_text=auto[1]))
|
||||
return
|
||||
# must_respond → add to pending and arm watchdog.
|
||||
with self._state_lock:
|
||||
self._pending_dialogs[dialog.id] = dialog
|
||||
loop = asyncio.get_running_loop()
|
||||
handle = loop.call_later(
|
||||
self._dialog_watchdogs[dialog.id] = asyncio.get_running_loop().call_later(
|
||||
self.dialog_timeout_s,
|
||||
lambda: asyncio.create_task(self._dialog_timeout_expired(dialog.id)),
|
||||
)
|
||||
self._dialog_watchdogs[dialog.id] = handle
|
||||
|
||||
# ── Responding ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _respond(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]
|
||||
) -> None:
|
||||
"""Bridge-fulfill for XHR-captured dialogs, else native CDP. Raises on native CDP failure."""
|
||||
async def _respond(self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]) -> None:
|
||||
"""Bridge-fulfill for XHR-captured dialogs, else native CDP.
|
||||
|
||||
Native path sends ``promptText`` only for prompt dialogs when
|
||||
``prompt_text`` is given, and raises on CDP failure; the bridge path
|
||||
(Fetch.fulfillRequest so the page unblocks) swallows failures.
|
||||
"""
|
||||
if dialog.bridge_request_id:
|
||||
await self._fulfill_bridge_request(dialog, accept=accept, prompt_text=prompt_text or "")
|
||||
else:
|
||||
await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text)
|
||||
|
||||
async def _native_handle_dialog(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]
|
||||
) -> None:
|
||||
"""Page.handleJavaScriptDialog; ``promptText`` sent only for prompt dialogs
|
||||
when ``prompt_text`` is given. Raises on CDP failure."""
|
||||
params: Dict[str, Any] = {"accept": accept}
|
||||
if prompt_text is not None and dialog.type == "prompt":
|
||||
params["promptText"] = prompt_text
|
||||
await self._cdp(
|
||||
"Page.handleJavaScriptDialog",
|
||||
params,
|
||||
session_id=dialog.cdp_session_id or None,
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
async def _fulfill_bridge_request(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
|
||||
) -> None:
|
||||
"""Resolve a bridge XHR via Fetch.fulfillRequest so the page unblocks."""
|
||||
if not dialog.bridge_request_id:
|
||||
return
|
||||
payload = {
|
||||
"accept": bool(accept),
|
||||
"prompt_text": prompt_text if dialog.type == "prompt" else "",
|
||||
"dialog_id": dialog.id,
|
||||
}
|
||||
body = json.dumps(payload).encode()
|
||||
try:
|
||||
await self._cdp(
|
||||
body = json.dumps({
|
||||
"accept": bool(accept),
|
||||
"prompt_text": (prompt_text or "") if dialog.type == "prompt" else "",
|
||||
"dialog_id": dialog.id,
|
||||
}).encode()
|
||||
await self._cdp_quiet(
|
||||
"Fetch.fulfillRequest",
|
||||
{
|
||||
"requestId": dialog.bridge_request_id,
|
||||
@@ -381,24 +303,23 @@ class DialogSupervisionMixin:
|
||||
],
|
||||
"body": base64.b64encode(body).decode(),
|
||||
},
|
||||
session_id=dialog.cdp_session_id or None,
|
||||
timeout=5.0,
|
||||
session_id=dialog.cdp_session_id or None, timeout=5.0, what=f"bridge fulfill {dialog.id}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("bridge fulfill failed for %s: %s", dialog.id, e)
|
||||
return
|
||||
params: Dict[str, Any] = {"accept": accept}
|
||||
if prompt_text is not None and dialog.type == "prompt":
|
||||
params["promptText"] = prompt_text
|
||||
await self._cdp("Page.handleJavaScriptDialog", params,
|
||||
session_id=dialog.cdp_session_id or None, timeout=5.0)
|
||||
|
||||
async def _auto_handle_dialog(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
|
||||
) -> None:
|
||||
"""Auto-policy response for a native dialog (already archived by the caller)."""
|
||||
async def _respond_quiet(self, dialog: PendingDialog, *, accept: bool, prompt_text: Optional[str]) -> None:
|
||||
"""Auto-policy / watchdog response (already archived by the caller); failures logged only."""
|
||||
try:
|
||||
await self._native_handle_dialog(dialog, accept=accept, prompt_text=prompt_text)
|
||||
await self._respond(dialog, accept=accept, prompt_text=prompt_text)
|
||||
except Exception as e:
|
||||
logger.debug("auto-handle CDP call failed for %s: %s", dialog.id, e)
|
||||
logger.debug("auto response failed for %s: %s", dialog.id, e)
|
||||
|
||||
async def _handle_dialog_cdp(
|
||||
self, dialog: PendingDialog, *, accept: bool, prompt_text: str
|
||||
) -> None:
|
||||
async def _handle_dialog_cdp(self, dialog: PendingDialog, *, accept: bool, prompt_text: str) -> None:
|
||||
"""Agent response path.
|
||||
|
||||
The dialog is retired regardless of outcome — a CDP error usually means
|
||||
@@ -416,19 +337,13 @@ class DialogSupervisionMixin:
|
||||
return
|
||||
logger.warning(
|
||||
"CDP supervisor %s: dialog %s (%s) auto-dismissed after %ss timeout",
|
||||
self.task_id,
|
||||
dialog_id,
|
||||
dialog.type,
|
||||
self.dialog_timeout_s,
|
||||
self.task_id, dialog_id, dialog.type, self.dialog_timeout_s,
|
||||
)
|
||||
try:
|
||||
# Archive with watchdog tag BEFORE unblocking the page.
|
||||
with self._state_lock:
|
||||
if self._pending_dialogs.pop(dialog_id, None) is not None:
|
||||
self._archive_dialog_locked(dialog, "watchdog")
|
||||
await self._respond(dialog, accept=False, prompt_text=None)
|
||||
except Exception as e:
|
||||
logger.debug("auto-dismiss failed for %s: %s", dialog_id, e)
|
||||
# Archive with watchdog tag BEFORE unblocking the page.
|
||||
with self._state_lock:
|
||||
if self._pending_dialogs.pop(dialog_id, None) is not None:
|
||||
self._archive_dialog_locked(dialog, "watchdog")
|
||||
await self._respond_quiet(dialog, accept=False, prompt_text=None)
|
||||
|
||||
# ── Bookkeeping ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -444,29 +359,20 @@ class DialogSupervisionMixin:
|
||||
|
||||
def _archive_dialog_locked(self, dialog: PendingDialog, closed_by: str) -> None:
|
||||
"""Move a pending dialog to the recent_dialogs ring buffer. Must hold state_lock."""
|
||||
record = DialogRecord(
|
||||
id=dialog.id,
|
||||
type=dialog.type,
|
||||
message=dialog.message,
|
||||
opened_at=dialog.opened_at,
|
||||
closed_at=time.time(),
|
||||
closed_by=closed_by,
|
||||
frame_id=dialog.frame_id,
|
||||
)
|
||||
self._recent_dialogs.append(record)
|
||||
self._recent_dialogs.append(DialogRecord(
|
||||
id=dialog.id, type=dialog.type, message=dialog.message, opened_at=dialog.opened_at,
|
||||
closed_at=time.time(), closed_by=closed_by, frame_id=dialog.frame_id,
|
||||
))
|
||||
self._recent_dialogs = _trim_ring(self._recent_dialogs, RECENT_DIALOGS_MAX)
|
||||
|
||||
async def _on_dialog_closed(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
async def _on_dialog_closed(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
|
||||
# ``Page.javascriptDialogClosed`` carries only ``result``/``userInput``, not
|
||||
# the message. Match by session id and clear the oldest native dialog on
|
||||
# it — the JS thread blocks while a dialog is up, so at most one is in
|
||||
# flight per session. Bridge dialogs resolve via Fetch.fulfillRequest.
|
||||
with self._state_lock:
|
||||
candidate_ids = [
|
||||
d.id
|
||||
for d in self._pending_dialogs.values()
|
||||
d.id for d in self._pending_dialogs.values()
|
||||
if d.cdp_session_id == session_id and d.bridge_request_id is None
|
||||
]
|
||||
if candidate_ids:
|
||||
|
||||
@@ -41,12 +41,7 @@ class FrameInfo:
|
||||
name: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
d = {
|
||||
"frame_id": self.frame_id,
|
||||
"url": self.url,
|
||||
"origin": self.origin,
|
||||
"is_oopif": self.is_oopif,
|
||||
}
|
||||
d = {"frame_id": self.frame_id, "url": self.url, "origin": self.origin, "is_oopif": self.is_oopif}
|
||||
if self.cdp_session_id:
|
||||
d["session_id"] = self.cdp_session_id
|
||||
if self.parent_frame_id:
|
||||
@@ -63,49 +58,36 @@ class FrameTrackingMixin:
|
||||
"""Page.enable + Runtime.enable + nested auto-attach on one session."""
|
||||
await self._cdp("Page.enable", session_id=session_id, timeout=timeout)
|
||||
await self._cdp("Runtime.enable", session_id=session_id, timeout=timeout)
|
||||
await self._cdp(
|
||||
"Target.setAutoAttach", _AUTO_ATTACH_PARAMS,
|
||||
session_id=session_id, timeout=timeout,
|
||||
)
|
||||
await self._cdp("Target.setAutoAttach", _AUTO_ATTACH_PARAMS, session_id=session_id, timeout=timeout)
|
||||
|
||||
def _on_frame_attached(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
def _on_frame_attached(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
|
||||
frame_id = params.get("frameId")
|
||||
if not frame_id:
|
||||
return
|
||||
with self._state_lock:
|
||||
self._frames[frame_id] = FrameInfo(
|
||||
frame_id=frame_id,
|
||||
url="",
|
||||
origin="",
|
||||
parent_frame_id=params.get("parentFrameId"),
|
||||
is_oopif=False,
|
||||
cdp_session_id=session_id,
|
||||
frame_id=frame_id, url="", origin="", parent_frame_id=params.get("parentFrameId"),
|
||||
is_oopif=False, cdp_session_id=session_id,
|
||||
)
|
||||
|
||||
def _on_frame_navigated(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
def _on_frame_navigated(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
|
||||
frame = params.get("frame") or {}
|
||||
frame_id = frame.get("id")
|
||||
if not frame_id:
|
||||
return
|
||||
with self._state_lock:
|
||||
existing = self._frames.get(frame_id)
|
||||
old = self._frames.get(frame_id)
|
||||
self._frames[frame_id] = FrameInfo(
|
||||
frame_id=frame_id,
|
||||
url=str(frame.get("url") or ""),
|
||||
origin=str(frame.get("securityOrigin") or frame.get("origin") or ""),
|
||||
parent_frame_id=frame.get("parentId") or (existing.parent_frame_id if existing else None),
|
||||
is_oopif=bool(existing.is_oopif if existing else False),
|
||||
cdp_session_id=existing.cdp_session_id if existing else session_id,
|
||||
name=str(frame.get("name") or (existing.name if existing else "")),
|
||||
parent_frame_id=frame.get("parentId") or (old.parent_frame_id if old else None),
|
||||
is_oopif=bool(old.is_oopif if old else False),
|
||||
cdp_session_id=old.cdp_session_id if old else session_id,
|
||||
name=str(frame.get("name") or (old.name if old else "")),
|
||||
)
|
||||
|
||||
def _on_frame_detached(
|
||||
self, params: Dict[str, Any], session_id: Optional[str]
|
||||
) -> None:
|
||||
def _on_frame_detached(self, params: Dict[str, Any], session_id: Optional[str]) -> None:
|
||||
"""Drop a frame only when it's truly gone.
|
||||
|
||||
``reason="swap"`` means the frame is migrating processes (e.g. promoted
|
||||
@@ -115,14 +97,11 @@ class FrameTrackingMixin:
|
||||
alive, so keep it until Target.detached + a later frameDetached clear it.
|
||||
"""
|
||||
frame_id = params.get("frameId")
|
||||
if not frame_id:
|
||||
return
|
||||
reason = str(params.get("reason") or "remove").lower()
|
||||
if reason == "swap":
|
||||
if not frame_id or str(params.get("reason") or "remove").lower() == "swap":
|
||||
return
|
||||
with self._state_lock:
|
||||
existing = self._frames.get(frame_id)
|
||||
if existing and existing.is_oopif and existing.cdp_session_id:
|
||||
old = self._frames.get(frame_id)
|
||||
if old and old.is_oopif and old.cdp_session_id:
|
||||
return
|
||||
self._frames.pop(frame_id, None)
|
||||
|
||||
@@ -132,22 +111,17 @@ class FrameTrackingMixin:
|
||||
target_type = info.get("type")
|
||||
if not sid or target_type not in {"iframe", "worker"}:
|
||||
return
|
||||
|
||||
# Record the frame with its OOPIF session id for interaction routing.
|
||||
if target_type == "iframe":
|
||||
# Record the frame with its OOPIF session id for interaction routing;
|
||||
# origin is filled by frameNavigated on the child session.
|
||||
target_id = info.get("targetId")
|
||||
with self._state_lock:
|
||||
existing = self._frames.get(target_id)
|
||||
old = self._frames.get(target_id)
|
||||
self._frames[target_id] = FrameInfo(
|
||||
frame_id=target_id,
|
||||
url=str(info.get("url") or ""),
|
||||
origin="", # filled by frameNavigated on the child session
|
||||
parent_frame_id=(existing.parent_frame_id if existing else None),
|
||||
is_oopif=True,
|
||||
cdp_session_id=sid,
|
||||
name=str(info.get("title") or (existing.name if existing else "")),
|
||||
frame_id=target_id, url=str(info.get("url") or ""), origin="",
|
||||
parent_frame_id=(old.parent_frame_id if old else None), is_oopif=True,
|
||||
cdp_session_id=sid, name=str(info.get("title") or (old.name if old else "")),
|
||||
)
|
||||
|
||||
# Enable child domains off-loop: awaiting the replies here would deadlock
|
||||
# because only the reader can resolve those Futures.
|
||||
asyncio.create_task(self._enable_child_domains(sid))
|
||||
@@ -178,25 +152,21 @@ class FrameTrackingMixin:
|
||||
self._frames[fid] = replace(frame, cdp_session_id=None)
|
||||
|
||||
def _build_frame_tree_locked(self) -> Dict[str, Any]:
|
||||
"""Build the capped frame_tree payload. Must be called under state lock."""
|
||||
frames = self._frames
|
||||
empty = {"top": None, "children": [], "truncated": False}
|
||||
if not frames:
|
||||
return empty
|
||||
"""Build the capped frame_tree payload. Must be called under state lock.
|
||||
|
||||
# Top frame: one with no parent, preferring oopif=False.
|
||||
Top frame = one with no parent, preferring oopif=False. BFS from it,
|
||||
capped by FRAME_TREE_MAX_ENTRIES and FRAME_TREE_MAX_OOPIF_DEPTH for
|
||||
OOPIF branches.
|
||||
"""
|
||||
frames = self._frames
|
||||
tops = [f for f in frames.values() if not f.parent_frame_id]
|
||||
top = next((f for f in tops if not f.is_oopif), tops[0] if tops else None)
|
||||
if top is None:
|
||||
return empty
|
||||
return {"top": None, "children": [], "truncated": False}
|
||||
|
||||
# BFS from top, capped by FRAME_TREE_MAX_ENTRIES and
|
||||
# FRAME_TREE_MAX_OOPIF_DEPTH for OOPIF branches.
|
||||
children: List[Dict[str, Any]] = []
|
||||
truncated = False
|
||||
queue: List[Tuple[FrameInfo, int]] = [
|
||||
(f, 1) for f in frames.values() if f.parent_frame_id == top.frame_id
|
||||
]
|
||||
queue: List[Tuple[FrameInfo, int]] = [(f, 1) for f in frames.values() if f.parent_frame_id == top.frame_id]
|
||||
visited: set[str] = {top.frame_id}
|
||||
while queue and len(children) < FRAME_TREE_MAX_ENTRIES:
|
||||
frame, depth = queue.pop(0)
|
||||
@@ -207,17 +177,9 @@ class FrameTrackingMixin:
|
||||
truncated = True
|
||||
continue
|
||||
children.append(frame.to_dict())
|
||||
for f in frames.values():
|
||||
if f.parent_frame_id == frame.frame_id and f.frame_id not in visited:
|
||||
queue.append((f, depth + 1))
|
||||
if queue:
|
||||
truncated = True
|
||||
|
||||
return {
|
||||
"top": top.to_dict(),
|
||||
"children": children,
|
||||
"truncated": truncated,
|
||||
}
|
||||
queue.extend((f, depth + 1) for f in frames.values()
|
||||
if f.parent_frame_id == frame.frame_id and f.frame_id not in visited)
|
||||
return {"top": top.to_dict(), "children": children, "truncated": truncated or bool(queue)}
|
||||
|
||||
# CDP event → handler(self, params, session_id); merged into CDPSupervisor._EVENT_HANDLERS.
|
||||
EVENT_HANDLERS: Dict[str, Callable[..., Any]] = {
|
||||
|
||||
+84
-112
@@ -4,6 +4,7 @@ When browser.backend is "browser-use", the model gets ``browser_exec`` tool
|
||||
instead of default browser tools
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
@@ -91,14 +92,20 @@ _URL_RE = re.compile(r"https?://[^\s'\"\\)]+", re.IGNORECASE)
|
||||
_FHS_BIN_DIRS = ("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin")
|
||||
|
||||
|
||||
def _quiet(fn: Callable[[], Any], default: Any, log_prefix: str = "") -> Any:
|
||||
"""``fn()``, or ``default`` on any exception (debug-logged when ``log_prefix`` is set)."""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e:
|
||||
if log_prefix:
|
||||
logger.debug("%s: %s", log_prefix, e)
|
||||
return default
|
||||
|
||||
|
||||
def _lazy_call(module: str, name: str, default: Any, log_prefix: str) -> Any:
|
||||
"""Call ``module.name()`` resolved at call time (so tests can stub the module or
|
||||
patch the attribute); on any failure log ``log_prefix`` and return ``default``."""
|
||||
try:
|
||||
return getattr(importlib.import_module(module), name)()
|
||||
except Exception as e:
|
||||
logger.debug("%s: %s", log_prefix, e)
|
||||
return default
|
||||
return _quiet(lambda: getattr(importlib.import_module(module), name)(), default, log_prefix)
|
||||
|
||||
|
||||
def _camofox_active(context: str = "") -> bool:
|
||||
@@ -106,6 +113,16 @@ def _camofox_active(context: str = "") -> bool:
|
||||
return _lazy_call("tools.browser_camofox", "is_camofox_mode", False, f"Camofox activity check failed{context}")
|
||||
|
||||
|
||||
def _real_profile_consented() -> bool:
|
||||
"""Whether the user opted in to real-profile local browsing (config read)."""
|
||||
return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed")
|
||||
|
||||
|
||||
def _lightpanda_engine_in_use() -> bool:
|
||||
return _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""),
|
||||
"lightpanda engine status unavailable")[0]
|
||||
|
||||
|
||||
def _set_cdp_env(env: dict, cdp: str) -> None:
|
||||
"""Export a CDP endpoint under the BU_CDP_* contract (http(s) → URL, else WS)."""
|
||||
env["BU_CDP_URL" if cdp.startswith(("http://", "https://")) else "BU_CDP_WS"] = cdp
|
||||
@@ -142,9 +159,9 @@ def _blocked_url_in_code(code: str) -> Optional[str]:
|
||||
def _base_subprocess_env() -> dict:
|
||||
from tools.browser_tool import _build_browser_env
|
||||
env = _build_browser_env()
|
||||
# The CLI runs under its own Python (uv tool / uvx); inherited
|
||||
# PYTHONPATH/PYTHONHOME (Hermes's venv) win over its site-packages → wrong-ABI
|
||||
# C-extensions (pydantic_core) and a crash. Strip both.
|
||||
# The CLI runs under its own Python (uv tool / uvx); inherited PYTHONPATH/PYTHONHOME
|
||||
# (Hermes's venv) win over its site-packages → wrong-ABI C-extensions (pydantic_core)
|
||||
# and a crash. Strip both.
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
env["PATH"] = _floor_subprocess_path(env.get("PATH", ""))
|
||||
@@ -153,24 +170,18 @@ def _base_subprocess_env() -> dict:
|
||||
|
||||
|
||||
def _floor_subprocess_path(path: str) -> str:
|
||||
"""Guarantee core system dirs survive onto the CLI subprocess PATH.
|
||||
|
||||
Profile workers (kanban bots, cron) can inherit a PATH of only version-manager
|
||||
dirs; the uv browser-use binary's POSIX sh trampoline resolves
|
||||
``dirname``/``realpath`` through PATH and dies (exit 127) without /usr/bin.
|
||||
Reuses browser_tool's ``_merge_browser_path`` floor, else appends FHS bin
|
||||
dirs. Windows .cmd shims don't trampoline through PATH, so no-op there.
|
||||
"""
|
||||
"""Guarantee core system dirs survive onto the CLI subprocess PATH: profile workers
|
||||
(kanban bots, cron) can inherit a PATH of only version-manager dirs, and the uv
|
||||
browser-use binary's POSIX sh trampoline resolves ``dirname``/``realpath`` through
|
||||
PATH and dies (exit 127) without /usr/bin. Reuses browser_tool's ``_merge_browser_path``
|
||||
floor, else appends FHS bin dirs. Windows .cmd shims don't trampoline: no-op there."""
|
||||
if os.name == "nt":
|
||||
return path
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.browser_tool import _merge_browser_path
|
||||
return _merge_browser_path(path or "")
|
||||
except Exception:
|
||||
pass
|
||||
parts = [p for p in (path or "").split(os.pathsep) if p]
|
||||
existing = set(parts)
|
||||
parts.extend(d for d in _FHS_BIN_DIRS if d not in existing and os.path.isdir(d))
|
||||
parts.extend(d for d in _FHS_BIN_DIRS if d not in set(parts) and os.path.isdir(d))
|
||||
return os.pathsep.join(parts)
|
||||
|
||||
|
||||
@@ -185,6 +196,10 @@ def _read_browser_cfg() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _use_gateway(browser_cfg: dict) -> bool:
|
||||
return is_truthy_value(browser_cfg.get("use_gateway"), default=False)
|
||||
|
||||
|
||||
def get_browser_backend() -> str:
|
||||
"""Return the configured browser backend key ("" = unset → default).
|
||||
|
||||
@@ -198,16 +213,14 @@ def get_browser_backend() -> str:
|
||||
|
||||
|
||||
def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool:
|
||||
"""True for pre-CLI direct-API Browser Use cloud configs"""
|
||||
"""True for pre-CLI direct-API Browser Use cloud configs. An explicit backend or
|
||||
a non-Browser-Use cloud_provider wins; Camofox is selected via env var, not
|
||||
cloud_provider, so a Camofox user with a stray BROWSER_USE_API_KEY keeps it."""
|
||||
if not isinstance(browser_cfg, dict) or browser_cfg.get("backend"):
|
||||
return False # an explicit backend choice wins
|
||||
provider = str(browser_cfg.get("cloud_provider") or "").strip().lower()
|
||||
if provider not in {"browser-use", ""}:
|
||||
return False # explicit local/Browserbase/… choices win
|
||||
if is_truthy_value(browser_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
# Camofox is selected via env var, not cloud_provider — a Camofox user
|
||||
# with a stray BROWSER_USE_API_KEY must keep their explicit choice.
|
||||
provider = str(browser_cfg.get("cloud_provider") or "").strip().lower()
|
||||
if provider not in {"browser-use", ""} or _use_gateway(browser_cfg):
|
||||
return False
|
||||
if _camofox_active(" during migration"):
|
||||
return False
|
||||
return bool(os.getenv("BROWSER_USE_API_KEY"))
|
||||
@@ -217,21 +230,17 @@ def is_browser_use_cli_mode() -> bool:
|
||||
"""True when the Browser Use CLI replaces the built-in browser stack.
|
||||
|
||||
Browser Use mode is the DEFAULT: unset ``browser.backend`` ("") enables it
|
||||
whenever the CLI is runnable (installed binary or uvx); ``browser.backend:
|
||||
off`` (or ``/browser use off``) keeps the built-in browser_* tools. Camofox
|
||||
always falls back to the built-in tools regardless — Firefox-based with a
|
||||
custom HTTP API and no CDP surface, so the CDP-only harness cannot drive it.
|
||||
whenever the CLI is runnable (installed binary or uvx), so browsing never
|
||||
silently breaks; ``browser.backend: off`` (or ``/browser use off``) keeps the
|
||||
built-in browser_* tools. Camofox always falls back to the built-in tools —
|
||||
Firefox-based with a custom HTTP API and no CDP surface for the harness.
|
||||
"""
|
||||
if _camofox_active():
|
||||
return False
|
||||
backend = get_browser_backend()
|
||||
if backend:
|
||||
return backend == _BACKEND_KEY
|
||||
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
|
||||
return True
|
||||
# Default (backend unset): Browser Use mode when the CLI can run at all;
|
||||
# otherwise keep the built-in tools so browsing never silently breaks.
|
||||
return _find_cli() is not None
|
||||
return is_legacy_browser_use_cloud_config(_read_browser_cfg()) or _find_cli() is not None
|
||||
|
||||
|
||||
_NOTICE_STAMP_NAME = ".browser_use_default_notice"
|
||||
@@ -246,16 +255,12 @@ def default_downgrade_notice() -> Optional[str]:
|
||||
if get_browser_backend() or _camofox_active() or _find_cli() is not None:
|
||||
return None # explicit choice / Camofox / CLI present — nothing downgraded
|
||||
stamp = Path(get_hermes_home()) / "cache" / _NOTICE_STAMP_NAME
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
if 0 <= time.time() - stamp.stat().st_mtime < _NOTICE_INTERVAL_S:
|
||||
return None
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp.touch()
|
||||
except OSError:
|
||||
pass
|
||||
return ("Browser Use CLI not found — using the built-in browser tools. "
|
||||
"Run `hermes tools` (Browser Automation → Browser Use) to install it, "
|
||||
"or `browser.backend: off` in config.yaml to silence this.")
|
||||
@@ -296,27 +301,25 @@ def _find_cli() -> Optional[List[str]]:
|
||||
|
||||
|
||||
def install_cli(timeout_s: int = 600) -> Tuple[bool, str]:
|
||||
"""Install the browser-use CLI persistently via ``uv tool install``
|
||||
(managed uv via ``ensure_uv`` → uv on PATH), linking the binary into
|
||||
``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for
|
||||
every profile without touching the user's PATH. Returns ``(ok, message)``;
|
||||
never raises.
|
||||
"""Install the browser-use CLI persistently via ``uv tool install`` (managed uv
|
||||
via ``ensure_uv`` → uv on PATH), linking the binary into ``$HERMES_HOME/bin``
|
||||
(``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for every profile without
|
||||
touching the user's PATH. Returns ``(ok, message)``; never raises.
|
||||
|
||||
MANAGED-FIRST: only the managed copy short-circuits. A browser-use on PATH is a
|
||||
user-level side install and must NOT prevent provisioning the canonical
|
||||
Hermes-managed copy (version drift, no updates via hermes tools).
|
||||
"""
|
||||
# MANAGED-FIRST: only the managed copy short-circuits. A browser-use on PATH
|
||||
# is a user-level side install and must NOT prevent provisioning the
|
||||
# canonical Hermes-managed copy (version drift, no updates via hermes tools).
|
||||
bin_dir = _managed_bin_dir()
|
||||
managed = shutil.which("browser-use", path=bin_dir)
|
||||
if managed:
|
||||
return True, f"browser-use CLI already installed ({managed})"
|
||||
|
||||
uv_bin: Optional[str] = None
|
||||
try:
|
||||
def _managed_uv() -> Optional[str]:
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
uv_bin = str(ensure_uv() or "") or None
|
||||
except Exception as e:
|
||||
logger.debug("Managed uv bootstrap unavailable: %s", e)
|
||||
uv_bin = uv_bin or shutil.which("uv")
|
||||
return str(ensure_uv() or "") or None
|
||||
|
||||
uv_bin = _quiet(_managed_uv, None, "Managed uv bootstrap unavailable") or shutil.which("uv")
|
||||
if not uv_bin:
|
||||
return False, ("uv is not available and could not be bootstrapped. Install uv "
|
||||
"(https://docs.astral.sh/uv/) and run `uv tool install browser-use`.")
|
||||
@@ -385,9 +388,9 @@ def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dic
|
||||
|
||||
if not _should_use_native_vision_fast_path():
|
||||
return None
|
||||
# History-reuse cap: this data URL bakes into the tool result and is
|
||||
# re-sent every later turn — same policy as the vision_analyze /
|
||||
# browser_vision native embeds (256 KB / 1568 px, JPEG quality ladder).
|
||||
# History-reuse cap: this data URL bakes into the tool result and is re-sent
|
||||
# every later turn — same policy as the vision_analyze / browser_vision native
|
||||
# embeds (256 KB / 1568 px, JPEG quality ladder).
|
||||
data_url = _resize_image_for_vision(
|
||||
Path(path), mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES,
|
||||
max_dimension=_EMBED_MAX_DIMENSION, force_jpeg=True,
|
||||
@@ -418,13 +421,9 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str
|
||||
private to this session and the own-tab preamble is skipped."""
|
||||
try:
|
||||
from tools.browser_tool import _get_session_info, _using_lightpanda_engine
|
||||
except Exception as e: # pragma: no cover — stubbed browser_tool in tests
|
||||
logger.debug("browser_tool lightpanda resolution unavailable: %s", e)
|
||||
return None
|
||||
try:
|
||||
if not _using_lightpanda_engine():
|
||||
return None
|
||||
except Exception as e:
|
||||
except Exception as e: # stubbed browser_tool in tests / engine lookup failure
|
||||
logger.debug("browser engine lookup failed: %s", e)
|
||||
return None
|
||||
err = _export_session_cdp(
|
||||
@@ -460,29 +459,22 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
|
||||
logger.debug("browser_tool backend resolution unavailable: %s", e)
|
||||
return None
|
||||
|
||||
try:
|
||||
override = _get_cdp_override()
|
||||
except Exception:
|
||||
override = ""
|
||||
override = _quiet(_get_cdp_override, "")
|
||||
if override:
|
||||
_set_cdp_env(env, override)
|
||||
return None
|
||||
|
||||
try:
|
||||
provider = _get_cloud_provider()
|
||||
except Exception as e:
|
||||
logger.debug("Cloud provider lookup failed: %s", e)
|
||||
provider = None
|
||||
provider = _quiet(_get_cloud_provider, None, "Cloud provider lookup failed")
|
||||
if provider is None:
|
||||
return _resolve_lightpanda_cdp(env, task_id, session_name)
|
||||
|
||||
# Browser Use direct-API configs: the CLI talks to BU cloud natively
|
||||
# (BU_AUTOSPAWN / auth login) — the legacy provider would create a second,
|
||||
# redundant session. The Nous-gateway variant (use_gateway: true) DOES resolve
|
||||
# through the provider: the gateway provisions the browser server-side and
|
||||
# returns its CDP URL, giving subscribers CLI mode with no raw key.
|
||||
# Browser Use direct-API configs: the CLI talks to BU cloud natively (BU_AUTOSPAWN /
|
||||
# auth login) — the legacy provider would create a second, redundant session. The
|
||||
# Nous-gateway variant (use_gateway: true) DOES resolve through the provider: the
|
||||
# gateway provisions the browser server-side and returns its CDP URL, giving
|
||||
# subscribers CLI mode with no raw key.
|
||||
provider_key = str(getattr(provider, "name", "") or "").strip().lower()
|
||||
if provider_key == _BACKEND_KEY and not is_truthy_value(_read_browser_cfg().get("use_gateway"), default=False):
|
||||
if provider_key == _BACKEND_KEY and not _use_gateway(_read_browser_cfg()):
|
||||
env[_PRIVATE_BROWSER_SENTINEL] = "1" # named BU cloud browsers are exclusive to their daemon
|
||||
return None
|
||||
|
||||
@@ -501,11 +493,6 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
|
||||
return err
|
||||
|
||||
|
||||
def _real_profile_consented() -> bool:
|
||||
"""Whether the user opted in to real-profile local browsing (config read)."""
|
||||
return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed")
|
||||
|
||||
|
||||
def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
|
||||
"""Point the harness at the user's real-profile copy-browser when consented.
|
||||
|
||||
@@ -527,19 +514,14 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
|
||||
logger.debug("real-profile backend resolution unavailable: %s", e)
|
||||
return None
|
||||
|
||||
try:
|
||||
if _get_cdp_override_raw():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
if _quiet(_get_cdp_override_raw, ""):
|
||||
return None
|
||||
|
||||
if not force_local:
|
||||
# Only auto-upgrade genuinely-local attaches; any cloud path (provider or
|
||||
# legacy BU cloud config) stays on its backend unless the model passes local=true.
|
||||
try:
|
||||
if _get_cloud_provider() is not None:
|
||||
return None
|
||||
except Exception:
|
||||
# Only auto-upgrade genuinely-local attaches; any cloud path (provider, provider
|
||||
# lookup failure, or legacy BU cloud config) stays on its backend unless the model
|
||||
# passes local=true.
|
||||
if _quiet(_get_cloud_provider, object()) is not None:
|
||||
return None
|
||||
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
|
||||
return None
|
||||
@@ -576,14 +558,13 @@ def _windows_popen_kwargs() -> dict:
|
||||
"""Hide the console the .cmd shim would flash on Windows (as browser_tool does)."""
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
try:
|
||||
def _flags() -> dict:
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
si = subprocess.STARTUPINFO()
|
||||
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
return {"creationflags": windows_hide_flags(), "startupinfo": si}
|
||||
except Exception as e:
|
||||
logger.debug("Windows hide-flags unavailable: %s", e)
|
||||
return {}
|
||||
|
||||
return _quiet(_flags, {}, "Windows hide-flags unavailable")
|
||||
|
||||
|
||||
def _clamp_timeout(timeout_s: Any) -> int:
|
||||
@@ -746,21 +727,12 @@ _HELPERS_DIGEST = (
|
||||
# lives with the session, not in the process-wide TTL-cached check_fn.
|
||||
|
||||
|
||||
def _lightpanda_engine_in_use() -> bool:
|
||||
return _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""),
|
||||
"lightpanda engine status unavailable")[0]
|
||||
|
||||
|
||||
def _description_header() -> str:
|
||||
"""Header tailored to whether the active model can see images natively"""
|
||||
if _lightpanda_engine_in_use(): # no screenshots at all, whatever the model can see
|
||||
return _HEADER_BASE + _HEADER_TEXT_ONLY + _HEADER_LIGHTPANDA
|
||||
try:
|
||||
from tools.vision_tools import _should_use_native_vision_fast_path
|
||||
if _should_use_native_vision_fast_path():
|
||||
return _HEADER_BASE + _HEADER_VISION
|
||||
except Exception:
|
||||
pass
|
||||
if _lazy_call("tools.vision_tools", "_should_use_native_vision_fast_path", False, ""):
|
||||
return _HEADER_BASE + _HEADER_VISION
|
||||
return _HEADER_BASE + _HEADER_TEXT_ONLY
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user