2027 lines
100 KiB
Python
2027 lines
100 KiB
Python
"""Restricted JSONL worker for the fast-browser prototype.
|
|
|
|
The worker deliberately delegates browser ownership to browser-harness. It does
|
|
not contain a second CDP client, navigation API, JavaScript execution API, or
|
|
model-facing browser handle. Only the small observe/click/fill surface below
|
|
is exposed to the TypeScript adapter. The session runtime below adds only
|
|
fixed, typed read/assertion, tab, navigation, and bounded evidence operations.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ipaddress
|
|
import signal
|
|
import subprocess
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
import base64
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Protocol
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
# Protocol limits are intentionally small. In particular, page text never
|
|
# becomes an unbounded JSONL response.
|
|
# Runtime evidence carries bounded base64 bytes; keep JSONL framing bounded
|
|
# above the published 8 MiB screenshot ceiling while retaining one line/request.
|
|
MAX_LINE_BYTES = 12 * 1024 * 1024
|
|
MAX_OUTPUT_BYTES = 12 * 1024 * 1024
|
|
MAX_CANDIDATES = 60
|
|
MAX_OBSERVATIONS = 8
|
|
MAX_STRING = 4_000
|
|
MAX_NAME = 240
|
|
MAX_FILL_TEXT = 8_000
|
|
MAX_READ_TEXT = 16_000
|
|
# JSONL transport remains bounded below the existing bridge line ceiling. The
|
|
# parent runtime writes these bytes to its own PrivateArtifactStore.
|
|
MAX_SCREENSHOT_BYTES = 8 * 1024 * 1024
|
|
MAX_DIAGNOSTICS_BYTES = 256 * 1024
|
|
MAX_EVENTS = 256
|
|
MAX_CONSOLE_FAILURES = 64
|
|
MAX_NETWORK_FAILURES = 64
|
|
MAX_EVENT_MESSAGE = 1_000
|
|
ACTION_TIMEOUT_SECONDS = 3.0
|
|
READ_ASSERTION_TIMEOUT_SECONDS = 3.0
|
|
NAVIGATION_TIMEOUT_SECONDS = 10.0
|
|
# Leave a small IPC margin below the published 5s popup budget so a normal
|
|
# timeout returns a structured result instead of racing the parent deadline.
|
|
POPUP_WAIT_SECONDS = 4.75
|
|
EXPECTED_BROWSER_HARNESS_VERSION = "0.1.13"
|
|
_INTERNAL_TARGET_PREFIXES = (
|
|
"chrome://", "chrome-untrusted://", "devtools://", "chrome-extension://", "about:",
|
|
)
|
|
_TEXT_INPUT_TYPES = {"", "text", "search", "email", "url", "tel", "number", "password"}
|
|
|
|
_FILL_ROLES = {"textbox", "searchbox", "spinbutton"}
|
|
_CLICK_ROLES = {
|
|
"button", "link", "checkbox", "radio", "switch", "tab", "menuitem",
|
|
"option", "combobox", "listbox", "treeitem",
|
|
}
|
|
|
|
|
|
class WorkerError(RuntimeError):
|
|
"""A safe, non-sensitive error suitable for the JSONL response."""
|
|
|
|
|
|
class BrowserOperationError(WorkerError):
|
|
"""Structured operation failure with no raw browser exception attached."""
|
|
|
|
def __init__(self, details: dict[str, Any]):
|
|
self.details = details
|
|
super().__init__(str(details.get("message", "browser operation failed")))
|
|
|
|
|
|
def _operation_error(
|
|
operation: str,
|
|
code: str,
|
|
phase: str,
|
|
message: str,
|
|
*,
|
|
mutation_state: str = "not_attempted",
|
|
retry: str = "never",
|
|
) -> BrowserOperationError:
|
|
# Keep this shape aligned with BrowserErrorDetails. Messages are selected
|
|
# from fixed, non-sensitive strings by callers; browser exception text is
|
|
# deliberately never inserted here.
|
|
return BrowserOperationError({
|
|
"code": code,
|
|
"operation": operation,
|
|
"phase": phase,
|
|
"message": message[:MAX_STRING],
|
|
"mutationState": mutation_state,
|
|
"retry": retry,
|
|
})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ElementMetadata:
|
|
backend_id: int
|
|
tag: str
|
|
input_type: str
|
|
role: str
|
|
name: str
|
|
visible: bool
|
|
disabled: bool
|
|
connected: bool = True
|
|
main_frame: bool = True
|
|
contenteditable: bool = False
|
|
ax_role: str = ""
|
|
ax_name: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CandidateRecord:
|
|
id: str
|
|
backend_id: int
|
|
role: str
|
|
name: str
|
|
actions: tuple[str, ...]
|
|
metadata: ElementMetadata
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ObservationRecord:
|
|
observation_id: str
|
|
target_id: str
|
|
frame_id: str
|
|
loader_id: str
|
|
url: str
|
|
title: str
|
|
candidates: tuple[CandidateRecord, ...]
|
|
|
|
|
|
def _safe_event_message(value: Any) -> str:
|
|
if not isinstance(value, str):
|
|
return "browser event"
|
|
value = " ".join(value.split())
|
|
# Console data is untrusted page content. It remains local evidence, but
|
|
# public shaping must not carry URL query/fragment tokens or obvious
|
|
# credential-bearing key/value diagnostics.
|
|
value = re.sub(r"https?://[^\s?#]+(?:[?#][^\s]*)?", lambda match: (_origin(match.group(0)) or "[url]"), value)
|
|
value = re.sub(r"(?i)(password|passwd|secret|token|authorization|cookie)(\s*[:=]\s*)[^\s,;]+", r"\1\2[redacted]", value)
|
|
value = "".join(ch for ch in value if ord(ch) >= 0x20 or ch in "\t\n")
|
|
return value[:MAX_EVENT_MESSAGE] or "browser event"
|
|
|
|
|
|
def _policy_event_message(value: str, policy: OriginPolicy) -> str:
|
|
return re.sub(
|
|
r"https?://[^\s?#]+(?:[?#][^\s]*)?",
|
|
lambda match: (_origin(match.group(0)) if _approved_origin(_origin(match.group(0)), policy) else "[url]"),
|
|
value,
|
|
)
|
|
|
|
|
|
def _safe_component(value: Any, label: str) -> str:
|
|
if not isinstance(value, str) or not value or len(value) > MAX_STRING or "\x00" in value:
|
|
raise WorkerError(f"invalid {label}")
|
|
# IDs may be opaque but must not become path traversal or separator input.
|
|
if value in {".", ".."} or "/" in value or "\\" in value:
|
|
raise WorkerError(f"invalid {label}")
|
|
return value
|
|
|
|
|
|
class BrowserBackend(Protocol):
|
|
"""The deliberately tiny seam used by offline tests and the real adapter."""
|
|
|
|
def current_tab(self) -> dict[str, Any]: ...
|
|
def document_identity(self) -> tuple[str, str]: ...
|
|
def accessibility_nodes(self) -> Iterable[dict[str, Any]]: ...
|
|
def metadata(self, backend_id: int) -> ElementMetadata: ...
|
|
def click(self, backend_id: int) -> None: ...
|
|
def fill(self, backend_id: int, text: str) -> None: ...
|
|
def value_matches(self, backend_id: int, text: str) -> bool: ...
|
|
def visible_text(self, text: str) -> bool: ...
|
|
def close(self) -> None: ...
|
|
|
|
|
|
def _origin(url: str) -> str | None:
|
|
"""Return a normalized web origin, rejecting non-web and malformed URLs."""
|
|
try:
|
|
parsed = urlsplit(url)
|
|
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname or "*" in parsed.hostname:
|
|
return None
|
|
if parsed.username is not None or parsed.password is not None:
|
|
return None
|
|
port = parsed.port
|
|
host = parsed.hostname.lower()
|
|
if "*" in host:
|
|
return None
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
host = f"[{host}]" if address.version == 6 else host
|
|
except ValueError:
|
|
pass
|
|
if (parsed.scheme.lower() == "http" and port == 80) or (
|
|
parsed.scheme.lower() == "https" and port == 443
|
|
):
|
|
port = None
|
|
netloc = host if port is None else f"{host}:{port}"
|
|
return urlunsplit((parsed.scheme.lower(), netloc, "", "", ""))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
OriginPolicy = str | frozenset[str]
|
|
|
|
|
|
def normalize_allowed_origins(origins: str | Iterable[str]) -> OriginPolicy:
|
|
if origins == "*":
|
|
return "*"
|
|
if isinstance(origins, str):
|
|
raise ValueError("allowed origins must be '*' or a JSON list")
|
|
normalized: set[str] = set()
|
|
for raw in origins:
|
|
if not isinstance(raw, str) or not raw or len(raw) > MAX_STRING:
|
|
raise ValueError("allowed origins must be short origin strings")
|
|
value = _origin(raw)
|
|
if value is None or raw not in {value, value + "/"}:
|
|
# Requiring origin-only input avoids silently broadening an
|
|
# allowlist containing a path, query, or credential.
|
|
raise ValueError(f"allowed origin is not an exact web origin: {raw!r}")
|
|
normalized.add(value)
|
|
if not normalized:
|
|
raise ValueError("at least one allowed origin is required")
|
|
return frozenset(normalized)
|
|
|
|
|
|
def _approved_origin(origin: str | None, policy: OriginPolicy) -> bool:
|
|
return origin is not None and (policy == "*" or origin in policy)
|
|
|
|
|
|
def validate_cdp_url(value: str) -> str:
|
|
if not isinstance(value, str) or len(value) > MAX_STRING:
|
|
raise ValueError("cdpUrl must be a short URL")
|
|
try:
|
|
parsed = urlsplit(value)
|
|
host = (parsed.hostname or "").lower()
|
|
try:
|
|
address = ipaddress.ip_address(host) if host else None
|
|
except ValueError:
|
|
address = None
|
|
loopback = host == "localhost" or (address is not None and address.is_loopback)
|
|
if parsed.scheme.lower() != "http" or not loopback or parsed.username is not None or parsed.password is not None:
|
|
raise ValueError
|
|
if parsed.port is None or not (1 <= parsed.port <= 65535):
|
|
raise ValueError
|
|
if parsed.fragment or parsed.query or parsed.path not in {"", "/"}:
|
|
raise ValueError
|
|
except (ValueError, TypeError):
|
|
raise ValueError("cdpUrl must be an http loopback endpoint") from None
|
|
return value
|
|
|
|
|
|
def _short(value: Any, limit: int = MAX_STRING) -> str:
|
|
if not isinstance(value, str):
|
|
raise WorkerError("expected string")
|
|
if len(value) > limit:
|
|
raise WorkerError("string exceeds protocol limit")
|
|
return value
|
|
|
|
|
|
def _ax_value(value: Any) -> str:
|
|
if isinstance(value, dict):
|
|
value = value.get("value", "")
|
|
return value if isinstance(value, str) else str(value or "")
|
|
|
|
|
|
def _safe_name(value: str, limit: int = MAX_NAME) -> str:
|
|
# The worker never reads or returns an input's current value. Strip
|
|
# controls so an AX label cannot forge a JSONL/log line in a downstream UI.
|
|
value = " ".join(value.split())
|
|
return value[:limit] or "(unnamed)"
|
|
|
|
|
|
def _candidate_actions(meta: ElementMetadata, ax_role: str) -> tuple[str, ...]:
|
|
role = (ax_role or meta.role or "").lower()
|
|
tag = meta.tag.lower()
|
|
input_type = meta.input_type.lower()
|
|
if input_type in {"hidden", "file"} or not meta.visible or meta.disabled or not meta.main_frame or meta.contenteditable:
|
|
return ()
|
|
actions: list[str] = []
|
|
if (tag == "textarea" and role not in {"combobox"}) or (tag == "input" and input_type in _TEXT_INPUT_TYPES):
|
|
actions.append("fill")
|
|
if role in _CLICK_ROLES or tag in {"button", "a", "summary"} or input_type in {"button", "submit", "reset", "checkbox", "radio"}:
|
|
actions.append("click")
|
|
# A combobox is exposed for filling only unless its AX role explicitly
|
|
# describes a clickable widget. Keep the list stable and duplicate-free.
|
|
return tuple(dict.fromkeys(actions))
|
|
|
|
|
|
def _is_visible_text(value: str) -> bool:
|
|
return bool(value and len(value) <= MAX_STRING)
|
|
|
|
|
|
class BrowserWorker:
|
|
"""Stateful, serialized implementation of the restricted bridge protocol."""
|
|
|
|
def __init__(self, backend: BrowserBackend, allowed_origins: OriginPolicy):
|
|
self.backend = backend
|
|
self.allowed_origins = normalize_allowed_origins(allowed_origins)
|
|
self._counter = 0
|
|
self._observations: dict[str, ObservationRecord] = {}
|
|
self._closed = False
|
|
self._blocked = False
|
|
# Explicit mutation boundary consumed by the session runtime. It is
|
|
# set before invoking the backend and never inferred from exception
|
|
# wording, which may be arbitrary browser text.
|
|
self._mutation_dispatched = False
|
|
self._mutation_verified = False
|
|
|
|
def _require_open(self) -> None:
|
|
if self._closed:
|
|
raise WorkerError("worker is closed")
|
|
if self._blocked:
|
|
raise WorkerError("browser left the approved origin; bridge stopped")
|
|
|
|
def _approved_url(self, url: str) -> str:
|
|
origin = _origin(url)
|
|
if not _approved_origin(origin, self.allowed_origins):
|
|
self._blocked = True
|
|
raise WorkerError("browser URL is outside the approved origins")
|
|
return url
|
|
|
|
def _state(self) -> tuple[dict[str, Any], str, str]:
|
|
tab = self.backend.current_tab()
|
|
if not isinstance(tab, dict):
|
|
raise WorkerError("invalid browser tab state")
|
|
target_id = tab.get("targetId") or tab.get("target_id")
|
|
url = tab.get("url")
|
|
title = tab.get("title") or ""
|
|
if not isinstance(target_id, str) or not target_id or not isinstance(url, str):
|
|
raise WorkerError("invalid browser tab identity")
|
|
self._approved_url(url)
|
|
frame_id, loader_id = self.backend.document_identity()
|
|
if not isinstance(frame_id, str) or not isinstance(loader_id, str):
|
|
raise WorkerError("invalid main-frame identity")
|
|
return {"target_id": target_id, "url": url, "title": _short(title, MAX_STRING)}, frame_id, loader_id
|
|
|
|
def observe(self) -> dict[str, Any]:
|
|
self._require_open()
|
|
tab, frame_id, loader_id = self._state()
|
|
self._counter += 1
|
|
observation_id = f"obs-{self._counter}-{secrets.token_hex(4)}"
|
|
records: list[CandidateRecord] = []
|
|
seen: set[int] = set()
|
|
for node in self.backend.accessibility_nodes():
|
|
if len(records) >= MAX_CANDIDATES:
|
|
break
|
|
if not isinstance(node, dict) or node.get("ignored") is True:
|
|
continue
|
|
backend_id = node.get("backendDOMNodeId")
|
|
if type(backend_id) is not int or backend_id <= 0 or backend_id in seen:
|
|
continue
|
|
seen.add(backend_id)
|
|
ax_role = _ax_value(node.get("role", "")).lower()
|
|
ax_name = _ax_value(node.get("name", ""))
|
|
# Do not spend several CDP round trips on static text/layout nodes.
|
|
if ax_role not in _FILL_ROLES | _CLICK_ROLES:
|
|
continue
|
|
try:
|
|
meta = self.backend.metadata(backend_id)
|
|
except Exception:
|
|
continue
|
|
if not meta.connected or meta.backend_id != backend_id:
|
|
continue
|
|
candidate_role = (ax_role or meta.ax_role or meta.role).lower()
|
|
actions = _candidate_actions(meta, candidate_role)
|
|
if not actions:
|
|
continue
|
|
# For fields prefer an author-supplied label/placeholder returned by
|
|
# metadata. AX names for a textbox must not accidentally echo its
|
|
# current value. The real backend's metadata.name follows this rule.
|
|
# A field's AX name can include state/value text on some custom
|
|
# controls. Never use it as a fallback for fill candidates.
|
|
name = _safe_name(meta.name if "fill" in actions else (meta.ax_name or meta.name or ax_name))
|
|
candidate_id = f"c-{len(records)}"
|
|
record = CandidateRecord(candidate_id, backend_id, candidate_role, name, actions, meta)
|
|
records.append(record)
|
|
snapshot = ObservationRecord(
|
|
observation_id, tab["target_id"], frame_id, loader_id, tab["url"], tab["title"], tuple(records)
|
|
)
|
|
self._observations[observation_id] = snapshot
|
|
while len(self._observations) > MAX_OBSERVATIONS:
|
|
self._observations.pop(next(iter(self._observations)))
|
|
return {
|
|
"observationId": observation_id,
|
|
"url": tab["url"],
|
|
"title": tab["title"],
|
|
"candidates": [
|
|
{"id": c.id, "role": c.role, "name": c.name, "actions": list(c.actions)}
|
|
for c in records
|
|
],
|
|
}
|
|
|
|
def _find_snapshot(self, request: dict[str, Any]) -> tuple[ObservationRecord, CandidateRecord]:
|
|
observation_id = request.get("observationId")
|
|
candidate_id = request.get("candidateId")
|
|
if not isinstance(observation_id, str) or not isinstance(candidate_id, str):
|
|
raise WorkerError("invalid action target")
|
|
snapshot = self._observations.pop(observation_id, None)
|
|
# Every attempted mutation invalidates the entire prior observation set,
|
|
# not only the particular handle chosen for this action.
|
|
self._observations.clear()
|
|
if snapshot is None:
|
|
raise WorkerError("observation is missing or already consumed")
|
|
candidate = next((item for item in snapshot.candidates if item.id == candidate_id), None)
|
|
if candidate is None:
|
|
raise WorkerError("candidate is not in the observation")
|
|
return snapshot, candidate
|
|
|
|
def _revalidate(self, snapshot: ObservationRecord, candidate: CandidateRecord, action: str) -> None:
|
|
tab, frame_id, loader_id = self._state()
|
|
if (
|
|
tab["target_id"] != snapshot.target_id
|
|
or tab["url"] != snapshot.url
|
|
or frame_id != snapshot.frame_id
|
|
or loader_id != snapshot.loader_id
|
|
):
|
|
raise WorkerError("observation is stale")
|
|
if action not in candidate.actions:
|
|
raise WorkerError("action is not allowed for candidate")
|
|
current = self.backend.metadata(candidate.backend_id)
|
|
current_role = (current.ax_role or current.role).lower()
|
|
current_name = current.name if action == "fill" else (current.name or current.ax_name)
|
|
current_actions = _candidate_actions(current, current_role)
|
|
if (
|
|
not current.connected
|
|
or not current.visible
|
|
or current.disabled
|
|
or current.tag.lower() != candidate.metadata.tag.lower()
|
|
or current.input_type.lower() != candidate.metadata.input_type.lower()
|
|
or current.main_frame != candidate.metadata.main_frame
|
|
or current.contenteditable != candidate.metadata.contenteditable
|
|
or current_role != candidate.role.lower()
|
|
or action not in current_actions
|
|
or _safe_name(current_name) != candidate.name
|
|
):
|
|
raise WorkerError("candidate changed or is unavailable")
|
|
|
|
def _post_action_state(self, snapshot: ObservationRecord, same_document: bool = False) -> dict[str, Any]:
|
|
tab, frame_id, loader_id = self._state()
|
|
if tab["target_id"] != snapshot.target_id:
|
|
raise WorkerError("browser tab changed during action")
|
|
if same_document and (frame_id != snapshot.frame_id or loader_id != snapshot.loader_id):
|
|
raise WorkerError("document changed during fill")
|
|
return tab
|
|
|
|
def act(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
self._require_open()
|
|
self._mutation_dispatched = False
|
|
self._mutation_verified = False
|
|
if not isinstance(request, dict):
|
|
raise WorkerError("invalid action request")
|
|
action = request.get("action")
|
|
if action not in {"click", "fill"}:
|
|
raise WorkerError("unsupported action")
|
|
snapshot, candidate = self._find_snapshot(request)
|
|
# Popping the snapshot happens before any revalidation or mutation:
|
|
# attempted mutations can never be replayed with the same observation.
|
|
if action == "click":
|
|
if set(request) - {"observationId", "candidateId", "action", "postcondition"}:
|
|
raise WorkerError("malformed click request")
|
|
postcondition = request.get("postcondition")
|
|
if not isinstance(postcondition, dict) or set(postcondition) != {"kind", "value"}:
|
|
raise WorkerError("click requires one postcondition")
|
|
kind = postcondition.get("kind")
|
|
value = postcondition.get("value")
|
|
if kind not in {"url", "text"} or not isinstance(value, str) or not value or len(value) > MAX_STRING:
|
|
raise WorkerError("invalid click postcondition")
|
|
if kind == "url" and not _approved_origin(_origin(value), self.allowed_origins):
|
|
raise WorkerError("postcondition URL is outside the approved origins")
|
|
else:
|
|
if set(request) - {"observationId", "candidateId", "action", "text", "postcondition"}:
|
|
raise WorkerError("malformed fill request")
|
|
text = request.get("text")
|
|
if not isinstance(text, str) or len(text) > MAX_FILL_TEXT:
|
|
raise WorkerError("fill text exceeds protocol limit")
|
|
postcondition = request.get("postcondition")
|
|
if postcondition is not None:
|
|
if not isinstance(postcondition, dict) or set(postcondition) != {"kind", "value"}:
|
|
raise WorkerError("invalid fill postcondition")
|
|
if postcondition.get("kind") not in {"url", "text"} or not isinstance(postcondition.get("value"), str):
|
|
raise WorkerError("invalid fill postcondition")
|
|
if not postcondition["value"] or len(postcondition["value"]) > MAX_STRING:
|
|
raise WorkerError("invalid fill postcondition")
|
|
if postcondition["kind"] == "url" and not _approved_origin(_origin(postcondition["value"]), self.allowed_origins):
|
|
raise WorkerError("fill postcondition URL is outside the approved origins")
|
|
self._revalidate(snapshot, candidate, action)
|
|
if action == "fill":
|
|
self._mutation_dispatched = True
|
|
self.backend.fill(candidate.backend_id, text)
|
|
deadline = time.monotonic() + ACTION_TIMEOUT_SECONDS
|
|
while time.monotonic() < deadline:
|
|
tab = self._post_action_state(snapshot, same_document=True)
|
|
value_verified = self.backend.value_matches(candidate.backend_id, text)
|
|
condition_verified = True
|
|
if postcondition is not None:
|
|
if postcondition["kind"] == "url":
|
|
condition_verified = tab.get("url") == postcondition["value"]
|
|
else:
|
|
condition_verified = self.backend.visible_text(postcondition["value"])
|
|
if value_verified and condition_verified:
|
|
self._mutation_verified = True
|
|
return {"verified": True, "evidence": "field value and postcondition verified" if postcondition else "field value verified"}
|
|
time.sleep(0.025)
|
|
return {"verified": False, "evidence": "field value or postcondition could not be verified"}
|
|
|
|
# A postcondition that already holds cannot prove that this click did
|
|
# anything. Refuse it before the one and only mutation.
|
|
tab = self._post_action_state(snapshot)
|
|
if postcondition["kind"] == "url":
|
|
already_satisfied = tab["url"] == postcondition["value"]
|
|
else:
|
|
already_satisfied = _is_visible_text(postcondition["value"]) and self.backend.visible_text(postcondition["value"])
|
|
if already_satisfied:
|
|
raise WorkerError("click postcondition was already satisfied")
|
|
|
|
# Exactly one click is ever issued. Verification polling below never
|
|
# retries the mutation, and checks origin before examining each result.
|
|
self._mutation_dispatched = True
|
|
self.backend.click(candidate.backend_id)
|
|
deadline = time.monotonic() + ACTION_TIMEOUT_SECONDS
|
|
while time.monotonic() < deadline:
|
|
tab = self._post_action_state(snapshot)
|
|
if postcondition["kind"] == "url":
|
|
if tab["url"] == postcondition["value"]:
|
|
self._mutation_verified = True
|
|
return {"verified": True, "evidence": "URL postcondition verified"}
|
|
elif _is_visible_text(postcondition["value"]) and self.backend.visible_text(postcondition["value"]):
|
|
self._mutation_verified = True
|
|
return {"verified": True, "evidence": "visible-text postcondition verified"}
|
|
time.sleep(0.025)
|
|
# _post_action_state performs the final approved-origin check too.
|
|
self._post_action_state(snapshot)
|
|
return {"verified": False, "evidence": "click postcondition was not verified"}
|
|
|
|
def close(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
self._observations.clear()
|
|
self.backend.close()
|
|
|
|
|
|
class RealBrowserBackend:
|
|
"""Adapter over browser-harness; no CDP implementation is duplicated here."""
|
|
|
|
def __init__(self, cdp_url: str, allowed_origins: OriginPolicy):
|
|
validate_cdp_url(cdp_url)
|
|
self.allowed_origins = normalize_allowed_origins(allowed_origins)
|
|
self._sandbox = Path(tempfile.mkdtemp(prefix="fast-browser-worker-"))
|
|
self._screenshot_dir = self._sandbox / "screenshots"
|
|
self._screenshot_dir.mkdir(mode=0o700)
|
|
try:
|
|
os.chmod(self._screenshot_dir, 0o700)
|
|
except OSError:
|
|
pass
|
|
self._daemon_ready = False
|
|
self._process: subprocess.Popen | None = None
|
|
self._attached_target: str | None = None
|
|
self._select_all_modifier: int | None = None
|
|
self._configure_environment(cdp_url, self._sandbox)
|
|
try:
|
|
self._load_harness()
|
|
self._start_daemon()
|
|
self._attach_initial_tab()
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
def _start_daemon(self) -> None:
|
|
# Own the actual child handle. admin.ensure_daemon detaches/retries its
|
|
# child and can launch Chrome; neither behavior belongs in this adapter.
|
|
self._process = subprocess.Popen(
|
|
[sys.executable, "-m", "browser_harness.daemon"],
|
|
env=dict(os.environ), stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
deadline = time.monotonic() + 5.0
|
|
while time.monotonic() < deadline:
|
|
if self._process.poll() is not None:
|
|
raise WorkerError("browser-harness daemon failed to connect to the configured endpoint")
|
|
try:
|
|
self._helpers.cdp("Target.getTargets", _response_timeout=0.2)
|
|
self._daemon_ready = True
|
|
return
|
|
except Exception:
|
|
time.sleep(0.025)
|
|
raise WorkerError("configured browser endpoint was not ready within five seconds")
|
|
|
|
@staticmethod
|
|
def _configure_environment(cdp_url: str, root: Path) -> None:
|
|
# The directory is retained by the instance through the environment;
|
|
# setting it before importing browser-harness prevents its .env and
|
|
# agent-helper discovery from reaching the user's workspace.
|
|
os.environ.update({
|
|
"BU_CDP_URL": cdp_url,
|
|
"BU_NAME": f"fast-{uuid.uuid4().hex[:20]}",
|
|
"BH_HOME": str(root / "home"),
|
|
"BH_CONFIG_DIR": str(root / "config"),
|
|
"BH_RUNTIME_DIR": str(root / "runtime"),
|
|
"BH_RUNTIME_DIR_SHARED": "0",
|
|
"BH_TMP_DIR": str(root / "tmp"),
|
|
"BH_TMP_DIR_SHARED": "0",
|
|
"BH_AGENT_WORKSPACE": str(root / "workspace"),
|
|
"BH_TELEMETRY": "0",
|
|
"BH_UPDATE_CHECK": "0",
|
|
"BH_RECORD": "0",
|
|
"BH_TAB_MARKER": "0",
|
|
"PYTHONNOUSERSITE": "1",
|
|
# Empty, rather than absent: browser-harness's .env loader uses
|
|
# setdefault and must not reintroduce cloud credentials or spawn.
|
|
"BROWSER_USE_API_KEY": "",
|
|
"BU_CDP_WS": "",
|
|
"BU_AUTOSPAWN": "",
|
|
"BU_BROWSER_ID": "",
|
|
"BU_PROFILE_ID": "",
|
|
"BU_PROXY_URL": "",
|
|
"BU_PROXY_USERNAME": "",
|
|
"BU_PROXY_PASSWORD": "",
|
|
})
|
|
|
|
def _load_harness(self) -> None:
|
|
# browser-harness must come from the configured Python environment's
|
|
# installed package. In particular, never import a temporary checkout
|
|
# via sys.path: that would make the runtime transport supply-chain
|
|
# mutable and would bypass the pinned environment chosen by the caller.
|
|
from importlib.metadata import PackageNotFoundError, version
|
|
try:
|
|
installed = version("browser-harness")
|
|
except PackageNotFoundError:
|
|
raise WorkerError(
|
|
"browser-harness is not installed in this Python environment; "
|
|
"install the pinned 0.1.13 package or set FAST_BROWSER_PYTHON"
|
|
) from None
|
|
if installed != EXPECTED_BROWSER_HARNESS_VERSION:
|
|
raise WorkerError(
|
|
f"browser-harness {EXPECTED_BROWSER_HARNESS_VERSION} is required, found {installed}; "
|
|
"use a dedicated pinned Python environment (no auto-upgrade is attempted)"
|
|
)
|
|
from browser_harness import helpers # type: ignore
|
|
self._helpers = helpers
|
|
|
|
def _attach_initial_tab(self) -> None:
|
|
"""Attach one eligible target when unambiguous, otherwise stay idle.
|
|
|
|
Runtime startup must remain usable with multiple tabs so callers can
|
|
list and explicitly select a target. A zero-tab startup still fails
|
|
closed because no operation can be served until a target exists.
|
|
"""
|
|
tabs = self._eligible_tabs()
|
|
if not tabs:
|
|
raise WorkerError("no approved-origin browser tab is available")
|
|
if len(tabs) > 1:
|
|
self._attached_target = None
|
|
return
|
|
target = tabs[0].get("targetId") or tabs[0].get("target_id")
|
|
if not isinstance(target, str) or not target:
|
|
raise WorkerError("approved browser tab has no target identity")
|
|
self.switch_tab(target)
|
|
|
|
def _eligible_tabs(self) -> list[dict[str, Any]]:
|
|
tabs = self._helpers.list_tabs(include_chrome=False)
|
|
return [
|
|
tab for tab in tabs
|
|
if isinstance(tab, dict)
|
|
and isinstance(tab.get("url"), str)
|
|
and _approved_origin(_origin(tab["url"]), self.allowed_origins)
|
|
and isinstance(tab.get("targetId") or tab.get("target_id"), str)
|
|
]
|
|
|
|
def _attach_approved_tab(self) -> None:
|
|
# A named browser-harness daemon owns a blank dedicated tab. It is
|
|
# intentionally not navigated: attach only to the one already-open
|
|
# approved tab and leave the user's selected tab/visibility untouched.
|
|
tabs = self._helpers.list_tabs(include_chrome=False)
|
|
approved = [
|
|
tab for tab in tabs
|
|
if isinstance(tab, dict)
|
|
and isinstance(tab.get("url"), str)
|
|
and _approved_origin(_origin(tab["url"]), self.allowed_origins)
|
|
]
|
|
if len(approved) != 1:
|
|
# Diagnose redirects without exposing titles, paths, query tokens,
|
|
# field values, or any content from outside the allowed origins.
|
|
web_origins = sorted({
|
|
origin for tab in tabs if isinstance(tab, dict)
|
|
and isinstance(tab.get("url"), str)
|
|
if (origin := _origin(tab["url"])) is not None
|
|
})
|
|
summary = json.dumps([origin[:240] for origin in web_origins[:10]])
|
|
raise WorkerError(
|
|
"expected exactly one existing approved-origin browser tab; "
|
|
f"found {len(approved)}; open web origins: {summary}"
|
|
)
|
|
target = approved[0].get("targetId") or approved[0].get("target_id")
|
|
if not isinstance(target, str) or not target:
|
|
raise WorkerError("approved browser tab has no target identity")
|
|
self._helpers.switch_tab(target, activate=False)
|
|
|
|
def switch_tab(self, target_id: str) -> None:
|
|
if not isinstance(target_id, str) or not target_id:
|
|
raise WorkerError("tab identity is unavailable")
|
|
# browser-harness 0.1.13 enables Page/DOM/Runtime/Network as part of
|
|
# its switch_tab set_session path. Re-enabling them here duplicates
|
|
# four CDP calls and can race the daemon's session setup.
|
|
self._session_id = self._helpers.switch_tab(target_id, activate=False)
|
|
self._attached_target = target_id
|
|
|
|
def list_tabs(self) -> list[dict[str, Any]]:
|
|
# helpers.list_tabs() and this opener lookup used to issue two identical
|
|
# Target.getTargets requests. Keep its page/internal filtering locally
|
|
# while deriving opener metadata from the same target inventory.
|
|
raw = self._helpers.cdp("Target.getTargets")
|
|
target_infos = raw.get("targetInfos", []) if isinstance(raw, dict) else []
|
|
if not isinstance(target_infos, list):
|
|
return []
|
|
result: list[dict[str, Any]] = []
|
|
for info in target_infos:
|
|
if not isinstance(info, dict) or info.get("type") != "page":
|
|
continue
|
|
url = info.get("url", "")
|
|
title = info.get("title", "")
|
|
if not isinstance(url, str) or not isinstance(title, str):
|
|
continue
|
|
# Match browser-harness helpers.list_tabs(include_chrome=False),
|
|
# including its startup-placeholder suppression.
|
|
if title.startswith("Starting agent ") and (
|
|
url in ("", "about:blank") or url.startswith("about:blank#")
|
|
):
|
|
continue
|
|
if url.startswith(_INTERNAL_TARGET_PREFIXES):
|
|
continue
|
|
target = info.get("targetId")
|
|
if not isinstance(target, str) or not target:
|
|
continue
|
|
opener = info.get("openerId")
|
|
result.append({
|
|
"targetId": target,
|
|
"target_id": target,
|
|
"url": url,
|
|
"title": title,
|
|
"openerTargetId": opener if isinstance(opener, str) and opener else None,
|
|
})
|
|
return result
|
|
|
|
def navigate(self, url: str) -> dict[str, Any]:
|
|
result = self._helpers.goto_url(url)
|
|
if isinstance(result, dict):
|
|
return result
|
|
return {}
|
|
|
|
def reload(self) -> dict[str, Any]:
|
|
return self._helpers.cdp("Page.reload")
|
|
|
|
def wait_ready(self, timeout: float = NAVIGATION_TIMEOUT_SECONDS) -> bool:
|
|
return bool(self._helpers.wait_for_load(timeout=timeout))
|
|
|
|
def visible_text_content(self) -> str:
|
|
# This is a fixed, worker-owned read operation. No caller expression,
|
|
# selector, HTML, form value, frame, or script output is accepted.
|
|
expression = """(() => {
|
|
const root = document.body;
|
|
if (!root) return '';
|
|
const blocked = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE',
|
|
'INPUT','TEXTAREA','SELECT','OPTION','DATALIST','OUTPUT']);
|
|
const visible = node => {
|
|
for (let e = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
|
|
e && e !== root.parentElement; e = e.parentElement) {
|
|
const s = getComputedStyle(e);
|
|
if (e.getAttribute('aria-hidden') === 'true' ||
|
|
s.display === 'none' || s.visibility === 'hidden' ||
|
|
s.opacity === '0') return false;
|
|
}
|
|
return true;
|
|
};
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
const parts = [];
|
|
let size = 0;
|
|
let node;
|
|
while ((node = walker.nextNode())) {
|
|
const parent = node.parentElement;
|
|
if (!parent || blocked.has(parent.tagName) || !visible(parent)) continue;
|
|
const text = String(node.nodeValue || '').replace(/[\\t\\n\\r ]+/g, ' ').trim();
|
|
if (text) {
|
|
parts.push(text);
|
|
size += text.length + 1;
|
|
if (size > 16001) break;
|
|
}
|
|
}
|
|
return parts.join(' ').replace(/ +/g, ' ').trim().slice(0, 16001);
|
|
})()"""
|
|
result = self._helpers.cdp("Runtime.evaluate", expression=expression,
|
|
returnByValue=True, awaitPromise=False)
|
|
remote = result.get("result") or {}
|
|
value = remote.get("value")
|
|
if not isinstance(value, str):
|
|
raise WorkerError("visible page text is unavailable")
|
|
return value
|
|
|
|
def drain_events(self) -> list[dict[str, Any]]:
|
|
try:
|
|
events = self._helpers.drain_events()
|
|
except Exception:
|
|
return []
|
|
return events if isinstance(events, list) else []
|
|
|
|
def screenshot_bytes(self) -> bytes:
|
|
# NamedTemporaryFile creates the file with O_EXCL inside the daemon's
|
|
# private 0700 sandbox; the helper only receives this worker-owned path.
|
|
handle = tempfile.NamedTemporaryFile(
|
|
prefix="shot-", suffix=".png", dir=self._screenshot_dir,
|
|
mode="w+b", delete=False,
|
|
)
|
|
path = Path(handle.name)
|
|
handle.close()
|
|
try:
|
|
os.chmod(path, 0o600)
|
|
self._helpers.capture_screenshot(str(path), full=False, max_dim=2000)
|
|
with open(path, "rb") as stream:
|
|
return stream.read(MAX_SCREENSHOT_BYTES + 1)
|
|
finally:
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
def close_tab(self, target_id: str) -> None:
|
|
self._helpers.close_tab(target_id)
|
|
|
|
def current_tab(self) -> dict[str, Any]:
|
|
return self._helpers.current_tab()
|
|
|
|
def document_identity(self) -> tuple[str, str]:
|
|
tree = self._helpers.cdp("Page.getFrameTree")
|
|
frame = (tree.get("frameTree") or {}).get("frame") or {}
|
|
frame_id = frame.get("id")
|
|
loader_id = frame.get("loaderId") or "no-loader"
|
|
if not isinstance(frame_id, str):
|
|
raise WorkerError("main frame is unavailable")
|
|
return frame_id, str(loader_id)
|
|
|
|
def accessibility_nodes(self) -> Iterable[dict[str, Any]]:
|
|
tree = self._helpers.cdp("Accessibility.getFullAXTree", depth=10)
|
|
nodes = tree.get("nodes")
|
|
return nodes if isinstance(nodes, list) else ()
|
|
|
|
def _call(self, backend_id: int, function: str, arguments: list[dict[str, Any]] | None = None) -> Any:
|
|
resolved = self._helpers.cdp("DOM.resolveNode", backendNodeId=backend_id)
|
|
object_id = (resolved.get("object") or {}).get("objectId")
|
|
if not isinstance(object_id, str):
|
|
raise WorkerError("element is unavailable")
|
|
try:
|
|
result = self._helpers.cdp(
|
|
"Runtime.callFunctionOn", objectId=object_id, functionDeclaration=function,
|
|
arguments=arguments or [], returnByValue=True, awaitPromise=False,
|
|
)
|
|
details = result.get("exceptionDetails")
|
|
remote = result.get("result") or {}
|
|
if details or remote.get("subtype") == "error" or "value" not in remote:
|
|
raise WorkerError("browser evaluation failed")
|
|
value = remote.get("value")
|
|
if not isinstance(value, (dict, bool, str, int, float, type(None))):
|
|
raise WorkerError("invalid browser metadata")
|
|
return value
|
|
finally:
|
|
try:
|
|
self._helpers.cdp("Runtime.releaseObject", objectId=object_id)
|
|
except Exception:
|
|
pass
|
|
|
|
def metadata(self, backend_id: int) -> ElementMetadata:
|
|
value = self._call(backend_id, """function () {
|
|
const e = this;
|
|
const style = getComputedStyle(e);
|
|
const type = (e.getAttribute('type') || '').toLowerCase();
|
|
const text = node => node ? String(node.innerText || node.textContent || '').trim() : '';
|
|
const labelledBy = (e.getAttribute('aria-labelledby') || '').split(/\\s+/)
|
|
.map(id => id ? text(document.getElementById(id)) : '').filter(Boolean).join(' ');
|
|
const labels = e.labels ? [...e.labels].map(text).filter(Boolean).join(' ') : '';
|
|
const visibleControlText = ['BUTTON', 'A', 'SUMMARY'].includes(e.tagName) ? text(e) : '';
|
|
// Never read .value: for fill candidates this is user data.
|
|
const label = e.getAttribute('aria-label') || labelledBy || labels ||
|
|
(visibleControlText || e.getAttribute('placeholder') ||
|
|
e.getAttribute('name') || e.getAttribute('id') || '');
|
|
return {
|
|
tag: e.tagName.toLowerCase(), type, role: e.getAttribute('role') || '',
|
|
name: String(label).slice(0, 240), connected: !!e.isConnected,
|
|
mainFrame: window.top === window,
|
|
contenteditable: e.isContentEditable,
|
|
visible: !!(e.getClientRects().length && style.display !== 'none' &&
|
|
style.visibility !== 'hidden' && style.opacity !== '0' &&
|
|
e.getAttribute('aria-hidden') !== 'true'),
|
|
disabled: !!(e.disabled || e.getAttribute('aria-disabled') === 'true' ||
|
|
e.matches(':disabled'))
|
|
};
|
|
}""")
|
|
if not isinstance(value, dict):
|
|
raise WorkerError("element metadata unavailable")
|
|
ax_role, ax_name = "", ""
|
|
try:
|
|
partial = self._helpers.cdp(
|
|
"Accessibility.getPartialAXTree", backendNodeId=backend_id, fetchRelatives=False
|
|
)
|
|
for node in partial.get("nodes", []) if isinstance(partial, dict) else []:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
ax_role = _ax_value(node.get("role", "")).lower()
|
|
ax_name = _ax_value(node.get("name", ""))
|
|
if ax_role or ax_name:
|
|
break
|
|
except Exception:
|
|
# DOM metadata still provides safe labels; accessibility is a
|
|
# refinement and not a reason to expose a broad CDP surface.
|
|
pass
|
|
return ElementMetadata(
|
|
backend_id, str(value.get("tag", "")), str(value.get("type", "")),
|
|
str(value.get("role", "")), " ".join(str(value.get("name", "")).split())[:MAX_NAME],
|
|
bool(value.get("visible")), bool(value.get("disabled")), bool(value.get("connected")),
|
|
bool(value.get("mainFrame", True)), bool(value.get("contenteditable", False)),
|
|
ax_role, ax_name,
|
|
)
|
|
|
|
def click(self, backend_id: int) -> None:
|
|
box = self._helpers.cdp("DOM.getBoxModel", backendNodeId=backend_id)
|
|
content = (box.get("model") or {}).get("content")
|
|
if not isinstance(content, list) or len(content) < 8:
|
|
raise WorkerError("candidate has no clickable box")
|
|
xs = [float(content[i]) for i in range(0, len(content), 2)]
|
|
ys = [float(content[i]) for i in range(1, len(content), 2)]
|
|
x, y = sum(xs) / len(xs), sum(ys) / len(ys)
|
|
hit = self._call(backend_id, """function (x, y) {
|
|
const hit = document.elementFromPoint(x, y);
|
|
return !!hit && (hit === this || this.contains(hit));
|
|
}""", [{"value": x}, {"value": y}])
|
|
if hit is not True:
|
|
raise WorkerError("candidate is covered or no longer at its observed position")
|
|
self._helpers.cdp("Input.dispatchMouseEvent", type="mousePressed", x=x, y=y, button="left", clickCount=1)
|
|
self._helpers.cdp("Input.dispatchMouseEvent", type="mouseReleased", x=x, y=y, button="left", clickCount=1)
|
|
|
|
def fill(self, backend_id: int, text: str) -> None:
|
|
self._helpers.cdp("DOM.focus", backendNodeId=backend_id)
|
|
focused = self._call(backend_id, """function () {
|
|
return document.activeElement === this;
|
|
}""")
|
|
if focused is not True:
|
|
raise WorkerError("candidate could not be focused")
|
|
# Select all without an arbitrary page script. Input.insertText is a
|
|
# single bounded browser operation and is substantially faster than
|
|
# dispatching up to 8,000 individual key events. Browser.getVersion is
|
|
# stable for this backend, so cache the platform modifier for its life.
|
|
if getattr(self, "_select_all_modifier", None) is None:
|
|
user_agent = self._helpers.cdp("Browser.getVersion").get("userAgent", "")
|
|
self._select_all_modifier = 4 if "Mac OS X" in user_agent or "Macintosh" in user_agent else 2
|
|
modifiers = self._select_all_modifier
|
|
select_all = {"key": "a", "code": "KeyA", "modifiers": modifiers,
|
|
"windowsVirtualKeyCode": 65, "nativeVirtualKeyCode": 65,
|
|
"commands": ["SelectAll"]}
|
|
self._helpers.cdp("Input.dispatchKeyEvent", type="rawKeyDown", **select_all)
|
|
self._helpers.cdp("Input.dispatchKeyEvent", type="keyUp", **{k: v for k, v in select_all.items() if k != "commands"})
|
|
self._helpers.press_key("Backspace")
|
|
if text:
|
|
self._helpers.cdp("Input.insertText", text=text)
|
|
self._call(backend_id, """function () {
|
|
this.dispatchEvent(new Event('input', {bubbles: true}));
|
|
this.dispatchEvent(new Event('change', {bubbles: true}));
|
|
return true;
|
|
}""")
|
|
|
|
def value_matches(self, backend_id: int, text: str) -> bool:
|
|
value = self._call(backend_id, """function () {
|
|
return 'value' in this ? String(this.value) : String(this.textContent || '');
|
|
}""")
|
|
return isinstance(value, str) and value == text
|
|
|
|
def visible_text(self, text: str) -> bool:
|
|
# The literal is JSON-encoded, never concatenated as executable code.
|
|
result = self._helpers.cdp(
|
|
"Runtime.evaluate", expression="""(() => {
|
|
const wanted = __TEXT__;
|
|
const visible = e => {
|
|
const s = getComputedStyle(e);
|
|
return !!(e.getClientRects().length && s.display !== 'none' &&
|
|
s.visibility !== 'hidden' && s.opacity !== '0');
|
|
};
|
|
return [...document.querySelectorAll('body *')].some(e =>
|
|
visible(e) && (e.innerText || '').includes(wanted));
|
|
})()""".replace("__TEXT__", json.dumps(text)), returnByValue=True,
|
|
)
|
|
return (result.get("result") or {}).get("value") is True
|
|
|
|
def close(self) -> None:
|
|
if getattr(self, "_closed", False):
|
|
return
|
|
self._closed = True
|
|
if getattr(self, "_daemon_ready", False):
|
|
try:
|
|
# This is the browser-harness daemon's ownership-aware
|
|
# shutdown; it closes only this named daemon's dedicated tab.
|
|
self._helpers._send({"meta": "shutdown"}, response_timeout=0.3)
|
|
except Exception:
|
|
pass
|
|
self._daemon_ready = False
|
|
process = getattr(self, "_process", None)
|
|
if process is not None:
|
|
try:
|
|
process.wait(timeout=0.3)
|
|
except subprocess.TimeoutExpired:
|
|
# asyncio's SIGINT handling runs the harness ownership cleanup.
|
|
try:
|
|
process.send_signal(signal.SIGINT)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
process.wait(timeout=0.3)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
process.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
process.wait(timeout=0.3)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
self._cleanup_sandbox()
|
|
|
|
def _cleanup_sandbox(self) -> None:
|
|
import shutil
|
|
shutil.rmtree(self._sandbox, ignore_errors=True)
|
|
|
|
|
|
@dataclass
|
|
class _RuntimeTab:
|
|
tab_id: str
|
|
target_id: str
|
|
kind: str
|
|
opener_target_id: str | None
|
|
url: str
|
|
title: str
|
|
active: bool = True
|
|
|
|
|
|
class BrowserRuntimeWorker(BrowserWorker):
|
|
"""Persistent session runtime for the published M2/M3/M4 operations."""
|
|
|
|
def __init__(self, backend: BrowserBackend, allowed_origins: OriginPolicy,
|
|
*, session_id: str | None = None):
|
|
super().__init__(backend, allowed_origins)
|
|
self.session_id = _safe_component(session_id or f"session-{uuid.uuid4().hex}", "session id")
|
|
self._base_policy = self.allowed_origins
|
|
self._tabs: dict[str, _RuntimeTab] = {}
|
|
self._target_to_tab: dict[str, str] = {}
|
|
self._selected_tab_id: str | None = None
|
|
self._tab_counter = 0
|
|
self._event_buffer: list[dict[str, Any]] = []
|
|
self._popup_candidates: set[str] = set()
|
|
self._claimed_popups: set[str] = set()
|
|
self._initializing = True
|
|
self._closed_runtime = False
|
|
self._refresh_tabs()
|
|
# Existing popup/page targets are baseline state, not newly-owned
|
|
# popup candidates for a later waitPopup call.
|
|
self._popup_candidates.clear()
|
|
self._initializing = False
|
|
# Exactly one initial target is safe to select. With multiple tabs no
|
|
# arbitrary page is selected; listTabs remains available.
|
|
if len(self._tabs) == 1:
|
|
self._selected_tab_id = next(iter(self._tabs))
|
|
self._attach(self._tabs[self._selected_tab_id].target_id, "selectTab")
|
|
|
|
def _fail(self, operation: str, code: str, phase: str, message: str,
|
|
*, mutation_state: str = "not_attempted", retry: str = "never") -> BrowserOperationError:
|
|
return _operation_error(operation, code, phase, message,
|
|
mutation_state=mutation_state, retry=retry)
|
|
|
|
def _backend_tabs(self) -> list[dict[str, Any]]:
|
|
listing = getattr(self.backend, "list_tabs", None)
|
|
if callable(listing):
|
|
try:
|
|
value = listing()
|
|
except Exception:
|
|
raise self._fail("listTabs", "worker_failure", "list", "browser tabs are unavailable")
|
|
return value if isinstance(value, list) else []
|
|
try:
|
|
current = self.backend.current_tab()
|
|
except Exception:
|
|
return []
|
|
return [current] if isinstance(current, dict) else []
|
|
|
|
def _refresh_tabs(self) -> None:
|
|
seen: set[str] = set()
|
|
raw_tabs = self._backend_tabs()
|
|
# Build the eligible target index before classifying any row. CDP target
|
|
# ordering is not an ownership signal: a popup may precede its opener.
|
|
entries: list[tuple[dict[str, Any], str, str]] = []
|
|
target_index: set[str] = set()
|
|
for raw in raw_tabs:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
target = raw.get("targetId") or raw.get("target_id")
|
|
url = raw.get("url")
|
|
if not isinstance(target, str) or not target or not isinstance(url, str):
|
|
continue
|
|
origin = _origin(url)
|
|
# Discovery is bounded by the immutable session base. A per-call
|
|
# context only filters/authorizes an operation; it must not make a
|
|
# tab handle disappear when the next call restores the base.
|
|
if not _approved_origin(origin, self._base_policy):
|
|
continue
|
|
entries.append((raw, target, url))
|
|
target_index.add(target)
|
|
for raw, target, url in entries:
|
|
seen.add(target)
|
|
tab_id = self._target_to_tab.get(target)
|
|
opener = raw.get("openerTargetId") or raw.get("openerId") or raw.get("opener_target_id")
|
|
opener_target = opener if isinstance(opener, str) and opener else None
|
|
owned_opener = opener_target if opener_target in target_index or opener_target in self._target_to_tab else None
|
|
if tab_id is None:
|
|
self._tab_counter += 1
|
|
tab_id = f"tab-{self._tab_counter}-{secrets.token_hex(6)}"
|
|
self._target_to_tab[target] = tab_id
|
|
kind = "popup" if owned_opener else "page"
|
|
self._tabs[tab_id] = _RuntimeTab(tab_id, target, kind, owned_opener,
|
|
url, _short(raw.get("title") or ""))
|
|
if kind == "popup" and not self._initializing:
|
|
self._popup_candidates.add(target)
|
|
else:
|
|
tab = self._tabs[tab_id]
|
|
tab.url = url
|
|
tab.title = _short(raw.get("title") or "")
|
|
if owned_opener:
|
|
was_popup = tab.kind == "popup"
|
|
tab.opener_target_id = owned_opener
|
|
tab.kind = "popup"
|
|
if not was_popup and not self._initializing:
|
|
self._popup_candidates.add(target)
|
|
# Handles to closed tabs are invalidated. Do not invent a replacement
|
|
# selection when a selected tab disappears.
|
|
for target, tab_id in list(self._target_to_tab.items()):
|
|
if target not in seen:
|
|
self._target_to_tab.pop(target, None)
|
|
self._tabs.pop(tab_id, None)
|
|
self._popup_candidates.discard(target)
|
|
self._claimed_popups.discard(target)
|
|
if self._selected_tab_id == tab_id:
|
|
self._selected_tab_id = None
|
|
|
|
def _attach(self, target_id: str, operation: str = "selectTab") -> None:
|
|
attached = getattr(self.backend, "_attached_target", None)
|
|
if attached is not None and attached == target_id:
|
|
return
|
|
switch = getattr(self.backend, "switch_tab", None)
|
|
if callable(switch):
|
|
try:
|
|
switch(target_id)
|
|
except Exception:
|
|
raise self._fail(operation, "no_tab", "attach", "tab is unavailable")
|
|
return
|
|
try:
|
|
current = self.backend.current_tab()
|
|
except Exception:
|
|
raise self._fail(operation, "no_tab", "attach", "tab is unavailable")
|
|
if current.get("targetId") != target_id and current.get("target_id") != target_id:
|
|
raise self._fail(operation, "no_tab", "attach", "tab is unavailable")
|
|
|
|
def _resolve_tab(self, request: dict[str, Any] | None, operation: str) -> _RuntimeTab:
|
|
self._refresh_tabs()
|
|
tab_id = request.get("tabId") if isinstance(request, dict) else None
|
|
if tab_id is not None:
|
|
if not isinstance(tab_id, str) or len(tab_id) > 200:
|
|
raise self._fail(operation, "invalid_request", "target", "tab handle is invalid")
|
|
tab = self._tabs.get(tab_id)
|
|
if tab is None:
|
|
raise self._fail(operation, "no_tab", "target", "tab handle is stale or unavailable")
|
|
else:
|
|
if self._selected_tab_id is None:
|
|
if not self._tabs:
|
|
raise self._fail(operation, "no_tab", "target", "no approved tab is available")
|
|
raise self._fail(operation, "ambiguous_tab", "target", "select an approved tab explicitly")
|
|
tab = self._tabs.get(self._selected_tab_id)
|
|
if tab is None:
|
|
self._selected_tab_id = None
|
|
raise self._fail(operation, "no_tab", "target", "selected tab is unavailable")
|
|
self._attach(tab.target_id, operation)
|
|
# The inherited worker uses the current backend target and current
|
|
# policy. Verify it once more without returning raw browser details.
|
|
try:
|
|
current = self.backend.current_tab()
|
|
url = current.get("url") if isinstance(current, dict) else None
|
|
except Exception:
|
|
raise self._fail(operation, "no_tab", "target", "tab is unavailable")
|
|
if not isinstance(url, str) or not _approved_origin(_origin(url), self.allowed_origins):
|
|
raise self._fail(operation, "policy_denied", "policy", "tab is outside the approved origins")
|
|
return tab
|
|
|
|
def _document_id(self, operation: str) -> str:
|
|
try:
|
|
frame, loader = self.backend.document_identity()
|
|
except Exception:
|
|
raise self._fail(operation, "worker_failure", "document", "document identity is unavailable")
|
|
if not isinstance(frame, str) or not isinstance(loader, str):
|
|
raise self._fail(operation, "worker_failure", "document", "document identity is unavailable")
|
|
return f"{frame}:{loader}"[:MAX_STRING]
|
|
|
|
def _public_tab(self, tab: _RuntimeTab) -> dict[str, Any]:
|
|
opener_tab = self._target_to_tab.get(tab.opener_target_id or "")
|
|
return {
|
|
"tabId": tab.tab_id,
|
|
"kind": "popup" if tab.kind == "popup" else "page",
|
|
**({"openerTabId": opener_tab} if opener_tab else {}),
|
|
"selected": tab.tab_id == self._selected_tab_id,
|
|
"owned": True,
|
|
"url": tab.url,
|
|
"origin": _origin(tab.url) or "",
|
|
"title": tab.title,
|
|
}
|
|
|
|
def _collect_events(self) -> None:
|
|
drain = getattr(self.backend, "drain_events", None)
|
|
if not callable(drain):
|
|
return
|
|
try:
|
|
events = drain()
|
|
except Exception:
|
|
return
|
|
if isinstance(events, list):
|
|
active_session = getattr(self.backend, "_session_id", None)
|
|
supported = {"Runtime.consoleAPICalled", "Network.requestWillBeSent",
|
|
"Network.loadingFailed", "Network.responseReceived"}
|
|
for event in events[:MAX_EVENTS]:
|
|
if not isinstance(event, dict) or event.get("method") not in supported:
|
|
continue
|
|
event_session = event.get("session_id")
|
|
# The pinned daemon includes the CDP session on every tapped
|
|
# event. Unattributed events are fail-closed: retaining them
|
|
# could mix a prior tab's private diagnostics into this target.
|
|
if not active_session or not isinstance(event_session, str) or event_session != active_session:
|
|
continue
|
|
method = event.get("method")
|
|
params = event.get("params") if isinstance(event.get("params"), dict) else {}
|
|
safe_params: dict[str, Any] = {}
|
|
if method == "Runtime.consoleAPICalled":
|
|
level = params.get("type")
|
|
if level not in {"error", "warning", "warn"}:
|
|
continue
|
|
safe_args: list[dict[str, str]] = []
|
|
args = params.get("args")
|
|
if isinstance(args, list):
|
|
for arg in args[:8]:
|
|
if not isinstance(arg, dict):
|
|
continue
|
|
value = arg.get("value")
|
|
description = arg.get("description")
|
|
if isinstance(value, (str, int, float, bool)):
|
|
safe_args.append({"value": _safe_event_message(str(value))})
|
|
elif isinstance(description, str):
|
|
safe_args.append({"value": _safe_event_message(description)})
|
|
safe_params = {"type": level, "args": safe_args}
|
|
elif method == "Network.requestWillBeSent":
|
|
request_id = params.get("requestId")
|
|
request = params.get("request") if isinstance(params.get("request"), dict) else {}
|
|
url = request.get("url")
|
|
origin = _origin(url) if isinstance(url, str) else None
|
|
if not isinstance(request_id, str) or not origin:
|
|
continue
|
|
safe_params = {"requestId": request_id,
|
|
"request": {"url": origin, "method": str(request.get("method") or "GET")[:32]}}
|
|
elif method == "Network.loadingFailed":
|
|
url = params.get("url")
|
|
origin = _origin(url) if isinstance(url, str) else None
|
|
safe_params = {"requestId": str(params.get("requestId") or "")[:200],
|
|
"url": origin or "", "blockedReason": bool(params.get("blockedReason"))}
|
|
else:
|
|
response = params.get("response") if isinstance(params.get("response"), dict) else {}
|
|
safe_params = {"requestId": str(params.get("requestId") or "")[:200],
|
|
"response": {"status": response.get("status")}}
|
|
# Preserve provenance with the sanitized event. The daemon's
|
|
# drain buffer is persistent across target switches; without
|
|
# retaining this session ID, evidence on a later tab could
|
|
# replay private diagnostics collected from an earlier tab.
|
|
self._event_buffer.append({
|
|
"method": method, "params": safe_params,
|
|
"session_id": active_session,
|
|
})
|
|
if len(self._event_buffer) > MAX_EVENTS:
|
|
del self._event_buffer[:-MAX_EVENTS]
|
|
|
|
def _read_text(self) -> tuple[str, bool]:
|
|
getter = getattr(self.backend, "visible_text_content", None)
|
|
if not callable(getter):
|
|
# Offline backends may expose the safe content under this explicit
|
|
# seam; never fall back to a caller-provided predicate or script.
|
|
getter = getattr(self.backend, "read_visible_text", None)
|
|
if not callable(getter):
|
|
raise self._fail("read", "unsupported", "visibleText", "visible page text is unavailable")
|
|
try:
|
|
value = getter()
|
|
except BrowserOperationError:
|
|
raise
|
|
except Exception:
|
|
raise self._fail("read", "worker_failure", "visibleText", "visible page text is unavailable")
|
|
if not isinstance(value, str):
|
|
raise self._fail("read", "worker_failure", "visibleText", "visible page text is unavailable")
|
|
# Remove control characters that can forge protocol lines, preserving
|
|
# ordinary Unicode and the rendered line structure.
|
|
clean = "".join(ch for ch in value if ch in "\t\n" or ord(ch) >= 0x20 and ch != "\x7f")
|
|
truncated = len(clean) > MAX_READ_TEXT
|
|
return clean[:MAX_READ_TEXT], truncated
|
|
|
|
def list_tabs(self) -> list[dict[str, Any]]:
|
|
self._refresh_tabs()
|
|
# Sort by safe public fields; raw target IDs never determine ordering.
|
|
return [self._public_tab(tab) for tab in sorted(
|
|
(tab for tab in self._tabs.values() if _approved_origin(_origin(tab.url), self.allowed_origins)),
|
|
key=lambda item: (item.kind, _origin(item.url) or "", item.url, item.title)
|
|
)]
|
|
|
|
def apply_context(self, context: dict[str, Any] | None, operation: str = "narrowOriginPolicy") -> None:
|
|
"""Set one effective policy for a single serialized dispatch."""
|
|
if context is None:
|
|
self.allowed_origins = self._base_policy
|
|
return
|
|
if not isinstance(context, dict) or set(context) != {"allowedOrigins"}:
|
|
raise self._fail(operation, "invalid_request", "policy", "invalid operation context")
|
|
try:
|
|
policy = normalize_allowed_origins(context["allowedOrigins"])
|
|
except (ValueError, TypeError):
|
|
raise self._fail(operation, "invalid_request", "policy", "origin policy is invalid")
|
|
if self._base_policy != "*" and (policy == "*" or not policy.issubset(self._base_policy)):
|
|
raise self._fail(operation, "policy_denied", "policy", "origin policy cannot broaden the session policy")
|
|
self.allowed_origins = policy
|
|
|
|
def narrow_origin_policy(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) != {"allowedOrigins"}:
|
|
raise self._fail("narrowOriginPolicy", "invalid_request", "policy", "invalid origin policy request")
|
|
try:
|
|
requested = normalize_allowed_origins(request["allowedOrigins"])
|
|
except (ValueError, TypeError):
|
|
raise self._fail("narrowOriginPolicy", "invalid_request", "policy", "origin policy is invalid")
|
|
if self._base_policy != "*" and (requested == "*" or not requested.issubset(self._base_policy)):
|
|
raise self._fail("narrowOriginPolicy", "policy_denied", "policy", "origin policy cannot broaden the session policy")
|
|
# If this request itself is context-scoped, the returned context must
|
|
# remain within that effective policy too. It is never persisted.
|
|
if self.allowed_origins != "*" and (requested == "*" or not requested.issubset(self.allowed_origins)):
|
|
raise self._fail("narrowOriginPolicy", "policy_denied", "policy", "origin policy cannot broaden the operation policy")
|
|
return {"allowedOrigins": "*" if requested == "*" else sorted(requested)}
|
|
|
|
def observe(self) -> dict[str, Any]:
|
|
self._resolve_tab({}, "observe")
|
|
try:
|
|
return super().observe()
|
|
except WorkerError as exc:
|
|
if isinstance(exc, BrowserOperationError):
|
|
raise
|
|
message = str(exc).lower()
|
|
code = "stale" if "stale" in message else "policy_denied" if "outside" in message else "worker_failure"
|
|
raise self._fail("observe", code, "observe", "browser observation failed")
|
|
|
|
def act(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict):
|
|
raise self._fail("act", "invalid_request", "request", "invalid action request")
|
|
self._resolve_tab({}, "act")
|
|
try:
|
|
return super().act(request)
|
|
except BrowserOperationError:
|
|
raise
|
|
except WorkerError as exc:
|
|
# The compatibility worker's policy fuse is scoped to its current
|
|
# effective policy. A narrower context must not poison the
|
|
# immutable base when a page redirects outside that context.
|
|
if self._blocked:
|
|
try:
|
|
current = self.backend.current_tab()
|
|
current_url = current.get("url") if isinstance(current, dict) else None
|
|
if _approved_origin(_origin(current_url), self._base_policy):
|
|
self._blocked = False
|
|
except Exception:
|
|
pass
|
|
# The explicit backend boundary, not arbitrary exception text,
|
|
# decides whether a dispatched mutation may have executed.
|
|
message = str(exc)
|
|
lowered = message.lower()
|
|
state = "unknown" if self._mutation_dispatched and not self._mutation_verified else "not_attempted"
|
|
code = "stale" if "stale" in lowered and not self._mutation_dispatched else "policy_denied" if "outside" in lowered else "worker_failure"
|
|
raise self._fail("act", code, "act", "browser action failed",
|
|
mutation_state=state, retry="never")
|
|
except Exception:
|
|
# Backend exceptions are opaque by design; the dispatch flag still
|
|
# preserves the conservative unknown-mutation classification.
|
|
state = "unknown" if self._mutation_dispatched and not self._mutation_verified else "not_attempted"
|
|
raise self._fail("act", "worker_failure", "act", "browser action failed",
|
|
mutation_state=state, retry="never")
|
|
except BaseException as exc:
|
|
# Cancellation can be raised as a BaseException by an embedding
|
|
# host. Preserve process signals, but classify post-dispatch
|
|
# cancellation exactly like any other uncertain mutation.
|
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
raise
|
|
state = "unknown" if self._mutation_dispatched and not self._mutation_verified else "not_attempted"
|
|
raise self._fail("act", "aborted", "act", "browser action was cancelled",
|
|
mutation_state=state, retry="never")
|
|
|
|
def read(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) - {"tabId"}:
|
|
raise self._fail("read", "invalid_request", "request", "invalid read request")
|
|
tab = self._resolve_tab(request, "read")
|
|
self._collect_events()
|
|
try:
|
|
current = self.backend.current_tab()
|
|
url = current.get("url")
|
|
title = current.get("title") or ""
|
|
except Exception:
|
|
raise self._fail("read", "worker_failure", "state", "page state is unavailable")
|
|
if not isinstance(url, str) or not _approved_origin(_origin(url), self.allowed_origins):
|
|
raise self._fail("read", "policy_denied", "policy", "page is outside the approved origins")
|
|
text, truncated = self._read_text()
|
|
return {"tab": self._public_tab(tab), "documentId": self._document_id("read"),
|
|
"url": url, "title": _short(title), "visibleText": text,
|
|
"visibleTextTruncated": truncated}
|
|
|
|
def assert_page(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) - {"tabId", "assertion", "wait"} or "assertion" not in request or request.get("wait") not in {"none", "bounded"}:
|
|
raise self._fail("assert", "invalid_request", "request", "invalid assertion request")
|
|
assertion = request["assertion"]
|
|
if not isinstance(assertion, dict) or set(assertion) != {"kind", "match", "value"}:
|
|
raise self._fail("assert", "invalid_request", "assertion", "invalid assertion")
|
|
kind, match, value = assertion.get("kind"), assertion.get("match"), assertion.get("value")
|
|
valid = ((kind == "url" and match in {"exact", "origin"}) or
|
|
(kind == "title" and match in {"exact", "contains"}) or
|
|
(kind == "visibleText" and match == "contains"))
|
|
if not valid or not isinstance(value, str) or not value or len(value) > MAX_STRING:
|
|
raise self._fail("assert", "invalid_request", "assertion", "invalid assertion")
|
|
if kind == "url" and (_origin(value) is None or (match == "exact" and not _approved_origin(_origin(value), self.allowed_origins))):
|
|
raise self._fail("assert", "policy_denied", "assertion", "assertion URL is outside the approved origins")
|
|
deadline = time.monotonic() + (READ_ASSERTION_TIMEOUT_SECONDS if request["wait"] == "bounded" else 0)
|
|
last: dict[str, Any] | None = None
|
|
while True:
|
|
last = self.read({"tabId": request.get("tabId")} if "tabId" in request else {})
|
|
actual = last["url"] if kind == "url" else last["title"] if kind == "title" else last["visibleText"]
|
|
if kind == "url" and match == "origin":
|
|
satisfied = _origin(actual) == _origin(value)
|
|
elif match == "contains":
|
|
satisfied = value in actual
|
|
else:
|
|
satisfied = actual == value
|
|
if satisfied:
|
|
status = "satisfied"
|
|
break
|
|
if request["wait"] == "none":
|
|
status = "not_satisfied"
|
|
break
|
|
if time.monotonic() >= deadline:
|
|
status = "timed_out"
|
|
break
|
|
time.sleep(0.025)
|
|
return {"status": status, "readOnly": True, "tab": last["tab"], "documentId": last["documentId"]}
|
|
|
|
def _wait_navigation(self, operation: str) -> None:
|
|
wait_ready = getattr(self.backend, "wait_ready", None)
|
|
if callable(wait_ready):
|
|
try:
|
|
ready = wait_ready(NAVIGATION_TIMEOUT_SECONDS)
|
|
except Exception:
|
|
raise self._fail(operation, "worker_failure", "wait", "navigation readiness is unavailable", mutation_state="unknown")
|
|
except BaseException as exc:
|
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
raise
|
|
raise self._fail(operation, "aborted", "wait", "navigation readiness was cancelled", mutation_state="unknown")
|
|
if ready is False:
|
|
raise self._fail(operation, "timeout", "wait", "navigation did not complete", mutation_state="unknown")
|
|
|
|
def navigate(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) - {"tabId", "url"} or not isinstance(request.get("url"), str):
|
|
raise self._fail("navigate", "invalid_request", "request", "invalid navigation request")
|
|
url = request["url"]
|
|
if len(url) > MAX_STRING or _origin(url) is None or not _approved_origin(_origin(url), self.allowed_origins):
|
|
raise self._fail("navigate", "policy_denied", "policy", "navigation URL is outside the approved origins")
|
|
tab = self._resolve_tab(request, "navigate")
|
|
nav = getattr(self.backend, "navigate", None)
|
|
if not callable(nav):
|
|
raise self._fail("navigate", "unsupported", "navigate", "navigation is unavailable")
|
|
self._mutation_dispatched = False
|
|
self._mutation_verified = False
|
|
try:
|
|
self._mutation_dispatched = True
|
|
nav(url)
|
|
except Exception:
|
|
raise self._fail("navigate", "worker_failure", "navigate", "navigation failed",
|
|
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
|
except BaseException as exc:
|
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
raise
|
|
raise self._fail("navigate", "aborted", "navigate", "navigation was cancelled",
|
|
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
|
self._wait_navigation("navigate")
|
|
self._refresh_tabs()
|
|
current = self._tabs.get(tab.tab_id)
|
|
if current is None or not _approved_origin(_origin(current.url), self.allowed_origins):
|
|
raise self._fail("navigate", "policy_denied", "redirect", "navigation left the approved origins", mutation_state="unknown")
|
|
self._attach(tab.target_id, "navigate")
|
|
self._mutation_verified = True
|
|
return {"status": "committed", "mutationState": "verified", "tab": self._public_tab(current),
|
|
"documentId": self._document_id("navigate")}
|
|
|
|
def reload(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) - {"tabId"}:
|
|
raise self._fail("reload", "invalid_request", "request", "invalid reload request")
|
|
tab = self._resolve_tab(request, "reload")
|
|
reload_page = getattr(self.backend, "reload", None)
|
|
if not callable(reload_page):
|
|
raise self._fail("reload", "unsupported", "reload", "reload is unavailable")
|
|
self._mutation_dispatched = False
|
|
self._mutation_verified = False
|
|
try:
|
|
self._mutation_dispatched = True
|
|
reload_page()
|
|
except Exception:
|
|
raise self._fail("reload", "worker_failure", "reload", "reload failed",
|
|
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
|
except BaseException as exc:
|
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
raise
|
|
raise self._fail("reload", "aborted", "reload", "reload was cancelled",
|
|
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
|
self._wait_navigation("reload")
|
|
self._refresh_tabs()
|
|
current = self._tabs.get(tab.tab_id)
|
|
if current is None:
|
|
raise self._fail("reload", "no_tab", "reload", "tab closed during reload", mutation_state="unknown")
|
|
self._attach(tab.target_id, "reload")
|
|
self._mutation_verified = True
|
|
return {"status": "committed", "mutationState": "verified", "tab": self._public_tab(current),
|
|
"documentId": self._document_id("reload")}
|
|
|
|
def select_tab(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) != {"tabId"} or not isinstance(request.get("tabId"), str):
|
|
raise self._fail("selectTab", "invalid_request", "request", "invalid tab selection request")
|
|
self._refresh_tabs()
|
|
tab = self._tabs.get(request["tabId"])
|
|
if tab is None:
|
|
raise self._fail("selectTab", "no_tab", "target", "tab handle is stale or unavailable")
|
|
if not _approved_origin(_origin(tab.url), self.allowed_origins):
|
|
raise self._fail("selectTab", "policy_denied", "policy", "tab is outside the approved origins")
|
|
self._attach(tab.target_id, "selectTab")
|
|
self._selected_tab_id = tab.tab_id
|
|
return {"selected": True, "tab": self._public_tab(tab)}
|
|
|
|
def wait_popup(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) != {"openerTabId"} or not isinstance(request.get("openerTabId"), str):
|
|
raise self._fail("waitPopup", "invalid_request", "request", "invalid popup request")
|
|
# Refresh first: an immediate popup may already be in the target
|
|
# inventory before waitPopup starts. Newly discovered popup candidates
|
|
# are tracked separately from baseline/previously claimed targets.
|
|
self._refresh_tabs()
|
|
opener = self._tabs.get(request["openerTabId"])
|
|
if opener is None:
|
|
raise self._fail("waitPopup", "no_tab", "target", "opener tab is stale or unavailable")
|
|
if not _approved_origin(_origin(opener.url), self.allowed_origins):
|
|
raise self._fail("waitPopup", "policy_denied", "policy", "opener tab is outside the approved origins")
|
|
deadline = time.monotonic() + POPUP_WAIT_SECONDS
|
|
while True:
|
|
self._refresh_tabs()
|
|
for target in list(self._popup_candidates):
|
|
tab_id = self._target_to_tab.get(target)
|
|
tab = self._tabs.get(tab_id) if tab_id else None
|
|
if (tab is not None and target not in self._claimed_popups
|
|
and tab.opener_target_id == opener.target_id
|
|
and _approved_origin(_origin(tab.url), self.allowed_origins)):
|
|
self._popup_candidates.discard(target)
|
|
self._claimed_popups.add(target)
|
|
return {"status": "found", "tab": self._public_tab(tab), "autoSwitched": False}
|
|
if time.monotonic() >= deadline:
|
|
return {"status": "timed_out", "autoSwitched": False}
|
|
time.sleep(0.025)
|
|
|
|
def close_tab(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) != {"tabId"} or not isinstance(request.get("tabId"), str):
|
|
raise self._fail("closeTab", "invalid_request", "request", "invalid tab close request")
|
|
self._refresh_tabs()
|
|
tab = self._tabs.get(request["tabId"])
|
|
if tab is None:
|
|
raise self._fail("closeTab", "no_tab", "target", "tab handle is stale or unavailable")
|
|
if not _approved_origin(_origin(tab.url), self.allowed_origins):
|
|
raise self._fail("closeTab", "policy_denied", "policy", "tab is outside the approved origins")
|
|
close = getattr(self.backend, "close_tab", None)
|
|
if not callable(close):
|
|
raise self._fail("closeTab", "unsupported", "close", "tab closing is unavailable")
|
|
self._mutation_dispatched = False
|
|
self._mutation_verified = False
|
|
try:
|
|
self._mutation_dispatched = True
|
|
close(tab.target_id)
|
|
except Exception:
|
|
raise self._fail("closeTab", "worker_failure", "close", "tab close failed",
|
|
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
|
except BaseException as exc:
|
|
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
raise
|
|
raise self._fail("closeTab", "aborted", "close", "tab close was cancelled",
|
|
mutation_state="unknown" if self._mutation_dispatched else "not_attempted")
|
|
# Do not assume a close acknowledgement means the target disappeared;
|
|
# verify the current target inventory before reporting a verified close.
|
|
try:
|
|
self._refresh_tabs()
|
|
except BrowserOperationError:
|
|
raise self._fail("closeTab", "worker_failure", "verify", "tab close could not be verified", mutation_state="unknown")
|
|
if tab.target_id in self._target_to_tab:
|
|
raise self._fail("closeTab", "worker_failure", "verify", "tab close could not be verified", mutation_state="unknown")
|
|
self._target_to_tab.pop(tab.target_id, None)
|
|
self._tabs.pop(tab.tab_id, None)
|
|
if self._selected_tab_id == tab.tab_id:
|
|
self._selected_tab_id = None
|
|
self._mutation_verified = True
|
|
return {"closed": True, "tabId": tab.tab_id, "mutationState": "verified"}
|
|
|
|
def _summarize_events(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
consoles: list[dict[str, Any]] = []
|
|
failures: list[dict[str, Any]] = []
|
|
request_by_id: dict[str, tuple[str, str]] = {}
|
|
active_session = getattr(self.backend, "_session_id", None)
|
|
if not isinstance(active_session, str) or not active_session:
|
|
return consoles, failures
|
|
for event in self._event_buffer[-MAX_EVENTS:]:
|
|
if not isinstance(event, dict) or event.get("session_id") != active_session:
|
|
continue
|
|
method = event.get("method") if isinstance(event, dict) else None
|
|
params = event.get("params") if isinstance(event, dict) else None
|
|
if not isinstance(params, dict):
|
|
continue
|
|
if method == "Runtime.consoleAPICalled":
|
|
level = params.get("type")
|
|
if level not in {"error", "warning", "warn"}:
|
|
continue
|
|
args = params.get("args")
|
|
pieces: list[str] = []
|
|
if isinstance(args, list):
|
|
for arg in args[:8]:
|
|
if isinstance(arg, dict):
|
|
value = arg.get("value")
|
|
if isinstance(value, (str, int, float, bool)):
|
|
pieces.append(str(value))
|
|
elif isinstance(arg.get("description"), str):
|
|
pieces.append(arg["description"])
|
|
message = _policy_event_message(
|
|
_safe_event_message(" ".join(pieces) or "console event"),
|
|
self.allowed_origins,
|
|
)
|
|
existing = next((item for item in consoles if item["level"] == ("warning" if level == "warn" else level) and item["message"] == message), None)
|
|
if existing:
|
|
existing["count"] += 1
|
|
elif len(consoles) < MAX_CONSOLE_FAILURES:
|
|
consoles.append({"level": "warning" if level == "warn" else level,
|
|
"message": message, "count": 1, "truncated": len(message) >= MAX_EVENT_MESSAGE})
|
|
elif method == "Network.requestWillBeSent":
|
|
request_id = params.get("requestId")
|
|
request = params.get("request")
|
|
if isinstance(request_id, str) and isinstance(request, dict):
|
|
url = request.get("url")
|
|
origin = _origin(url) if isinstance(url, str) else None
|
|
if origin and _approved_origin(origin, self.allowed_origins):
|
|
request_by_id[request_id] = (str(request.get("method") or "GET")[:32], origin)
|
|
elif method == "Network.loadingFailed":
|
|
request_id = params.get("requestId")
|
|
method_origin = request_by_id.get(request_id) if isinstance(request_id, str) else None
|
|
if method_origin is None and isinstance(params.get("url"), str):
|
|
origin = _origin(params["url"])
|
|
if origin and _approved_origin(origin, self.allowed_origins):
|
|
method_origin = ("GET", origin)
|
|
if method_origin and len(failures) < MAX_NETWORK_FAILURES:
|
|
failures.append({"method": method_origin[0], "origin": method_origin[1],
|
|
"category": "blocked" if params.get("blockedReason") else "network"})
|
|
elif method == "Network.responseReceived":
|
|
request_id = params.get("requestId")
|
|
response = params.get("response")
|
|
method_origin = request_by_id.get(request_id) if isinstance(request_id, str) else None
|
|
if method_origin and isinstance(response, dict):
|
|
status = response.get("status")
|
|
if isinstance(status, (int, float)) and status >= 400 and len(failures) < MAX_NETWORK_FAILURES:
|
|
failures.append({"method": method_origin[0], "origin": method_origin[1],
|
|
"status": int(status), "category": "http"})
|
|
return consoles, failures
|
|
|
|
def capture_evidence(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
if not isinstance(request, dict) or set(request) - {"runId", "reason", "operation"} or not isinstance(request.get("runId"), str) or request.get("reason") not in {"failure", "requested"}:
|
|
raise self._fail("captureEvidence", "invalid_request", "request", "invalid evidence request")
|
|
run_id = request["runId"]
|
|
try:
|
|
_safe_component(run_id, "run id")
|
|
except WorkerError:
|
|
raise self._fail("captureEvidence", "invalid_request", "request", "invalid evidence request")
|
|
operations = {"observe", "act", "read", "assert", "navigate", "reload", "listTabs", "selectTab", "waitPopup", "closeTab", "captureEvidence", "narrowOriginPolicy", "close"}
|
|
if request.get("operation") is not None and request.get("operation") not in operations:
|
|
raise self._fail("captureEvidence", "invalid_request", "request", "invalid evidence operation")
|
|
self._collect_events()
|
|
consoles, failures = self._summarize_events()
|
|
screenshot_payload: dict[str, Any] | None = None
|
|
diagnostics_payload: dict[str, Any] | None = None
|
|
status = "complete"
|
|
target_available = self._selected_tab_id is not None
|
|
if target_available:
|
|
try:
|
|
self._resolve_tab({}, "captureEvidence")
|
|
except BrowserOperationError:
|
|
target_available = False
|
|
else:
|
|
status = "partial"
|
|
shot = getattr(self.backend, "screenshot_bytes", None)
|
|
if callable(shot) and target_available:
|
|
try:
|
|
data = shot()
|
|
if not isinstance(data, (bytes, bytearray)):
|
|
raise WorkerError("screenshot is unavailable")
|
|
raw = bytes(data)
|
|
clipped = raw[:MAX_SCREENSHOT_BYTES]
|
|
screenshot_payload = {
|
|
"kind": "screenshot", "contentType": "image/png",
|
|
"encoding": "base64", "dataBase64": base64.b64encode(clipped).decode("ascii"),
|
|
"byteLength": len(clipped), "truncated": len(raw) > len(clipped),
|
|
}
|
|
if len(raw) > len(clipped):
|
|
status = "partial"
|
|
except Exception:
|
|
status = "partial"
|
|
else:
|
|
status = "partial"
|
|
try:
|
|
payload = json.dumps({"console": consoles, "failedRequests": failures},
|
|
ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
# Keep the diagnostics artifact valid JSON while enforcing its
|
|
# transport ceiling; event summaries are evidence, never a reason
|
|
# to fail the original browser operation.
|
|
while len(payload) > MAX_DIAGNOSTICS_BYTES and (consoles or failures):
|
|
if len(consoles) >= len(failures) and consoles:
|
|
consoles.pop()
|
|
elif failures:
|
|
failures.pop()
|
|
payload = json.dumps({"console": consoles, "failedRequests": failures},
|
|
ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
if len(payload) > MAX_DIAGNOSTICS_BYTES:
|
|
raise WorkerError("diagnostics exceed their size limit")
|
|
diagnostics_payload = {
|
|
"kind": "diagnostics", "contentType": "application/json",
|
|
"encoding": "base64", "dataBase64": base64.b64encode(payload).decode("ascii"),
|
|
"byteLength": len(payload), "truncated": False,
|
|
}
|
|
except Exception:
|
|
status = "partial" if status == "complete" else status
|
|
if screenshot_payload is None and diagnostics_payload is None:
|
|
status = "unavailable"
|
|
return {"status": status,
|
|
**({"screenshot": screenshot_payload} if screenshot_payload else {}),
|
|
**({"diagnostics": diagnostics_payload} if diagnostics_payload else {}),
|
|
"console": consoles, "failedRequests": failures}
|
|
|
|
def close(self) -> None:
|
|
if self._closed_runtime:
|
|
return
|
|
self._closed_runtime = True
|
|
super().close()
|
|
|
|
|
|
# Descriptive aliases for adapters that refer to the published runtime rather
|
|
# than the historical observe/act worker name.
|
|
BrowserRuntime = BrowserRuntimeWorker
|
|
RuntimeWorker = BrowserRuntimeWorker
|
|
|
|
|
|
def _response(request_id: Any, *, result: Any = None, error: str | None = None,
|
|
details: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if type(request_id) is not int or request_id < 0:
|
|
raise WorkerError("invalid request id")
|
|
if error is not None:
|
|
response = {"id": request_id, "ok": False, "error": str(error)[:MAX_STRING]}
|
|
if details is not None:
|
|
response["details"] = details
|
|
return response
|
|
return {"id": request_id, "ok": True, "result": result}
|
|
|
|
|
|
def process_request(worker: BrowserWorker, request: Any) -> dict[str, Any]:
|
|
runtime_worker = isinstance(worker, BrowserRuntimeWorker)
|
|
startup_worker = isinstance(worker, StartupFailureWorker)
|
|
if not isinstance(request, dict) or type(request.get("id")) is not int:
|
|
raise WorkerError("malformed request")
|
|
if runtime_worker or (startup_worker and "operation" in request):
|
|
if "operation" not in request:
|
|
raise WorkerError("malformed transport request")
|
|
op = request.get("operation", {}).get("op") if isinstance(request.get("operation"), dict) else None
|
|
else:
|
|
if "op" not in request:
|
|
raise WorkerError("malformed request")
|
|
op = request.get("op")
|
|
request_id = request["id"]
|
|
if isinstance(worker, BrowserRuntimeWorker):
|
|
# Published transport shape: operation is the typed BrowserOperation
|
|
# union and context is an optional one-dispatch policy narrowing.
|
|
if (set(request) - {"id", "operation", "context"} or "operation" not in request
|
|
or not isinstance(request["operation"], dict)
|
|
or ("context" in request and request["context"] is None)):
|
|
raise WorkerError("malformed transport request")
|
|
operation = request["operation"]
|
|
if set(operation) - {"op", "request"} or not isinstance(operation.get("op"), str):
|
|
raise WorkerError("malformed transport operation")
|
|
operation_name = operation["op"]
|
|
if operation_name in {"observe", "listTabs", "close"} and set(operation) != {"op"}:
|
|
raise WorkerError("malformed transport operation")
|
|
if operation_name not in {"observe", "listTabs", "close"} and (set(operation) != {"op", "request"} or not isinstance(operation.get("request"), dict)):
|
|
raise WorkerError("malformed transport operation")
|
|
worker.apply_context(request.get("context"), operation_name)
|
|
try:
|
|
body = operation.get("request")
|
|
if operation_name == "observe": result = worker.observe()
|
|
elif operation_name == "act": result = worker.act(body)
|
|
elif operation_name == "read": result = worker.read(body)
|
|
elif operation_name == "assert": result = worker.assert_page(body)
|
|
elif operation_name == "navigate": result = worker.navigate(body)
|
|
elif operation_name == "reload": result = worker.reload(body)
|
|
elif operation_name == "listTabs": result = worker.list_tabs()
|
|
elif operation_name == "selectTab": result = worker.select_tab(body)
|
|
elif operation_name == "waitPopup": result = worker.wait_popup(body)
|
|
elif operation_name == "closeTab": result = worker.close_tab(body)
|
|
elif operation_name == "captureEvidence": result = worker.capture_evidence(body)
|
|
elif operation_name == "narrowOriginPolicy": result = worker.narrow_origin_policy(body)
|
|
elif operation_name == "close":
|
|
worker.close()
|
|
result = {"closed": True}
|
|
else:
|
|
raise WorkerError("unsupported transport operation")
|
|
finally:
|
|
# An omitted context always restores the immutable base, and a
|
|
# supplied context never leaks into the next JSONL request.
|
|
if not worker._closed_runtime:
|
|
worker.apply_context(None)
|
|
return _response(request_id, result={"op": operation_name, "result": result})
|
|
if isinstance(worker, StartupFailureWorker) and "operation" in request:
|
|
if set(request) - {"id", "operation", "context"}:
|
|
raise WorkerError("malformed transport request")
|
|
worker.observe() # raises the bounded startup detail
|
|
if op == "observe":
|
|
if set(request) != {"id", "op"}:
|
|
raise WorkerError("malformed observe request")
|
|
return _response(request_id, result=worker.observe())
|
|
if op == "act":
|
|
if set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed act request")
|
|
return _response(request_id, result=worker.act(request["request"]))
|
|
if op == "read":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed read request")
|
|
return _response(request_id, result=worker.read(request["request"]))
|
|
if op == "assert":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed assert request")
|
|
return _response(request_id, result=worker.assert_page(request["request"]))
|
|
if op == "navigate":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed navigate request")
|
|
return _response(request_id, result=worker.navigate(request["request"]))
|
|
if op == "reload":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed reload request")
|
|
return _response(request_id, result=worker.reload(request["request"]))
|
|
if op == "listTabs":
|
|
if not runtime_worker or set(request) != {"id", "op"}:
|
|
raise WorkerError("malformed listTabs request")
|
|
return _response(request_id, result=worker.list_tabs())
|
|
if op == "selectTab":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed selectTab request")
|
|
return _response(request_id, result=worker.select_tab(request["request"]))
|
|
if op == "waitPopup":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed waitPopup request")
|
|
return _response(request_id, result=worker.wait_popup(request["request"]))
|
|
if op == "closeTab":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed closeTab request")
|
|
return _response(request_id, result=worker.close_tab(request["request"]))
|
|
if op == "captureEvidence":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed captureEvidence request")
|
|
return _response(request_id, result=worker.capture_evidence(request["request"]))
|
|
if op == "narrowOriginPolicy":
|
|
if not runtime_worker or set(request) != {"id", "op", "request"} or not isinstance(request.get("request"), dict):
|
|
raise WorkerError("malformed narrowOriginPolicy request")
|
|
return _response(request_id, result=worker.narrow_origin_policy(request["request"]))
|
|
if op == "close":
|
|
if set(request) != {"id", "op"}:
|
|
raise WorkerError("malformed close request")
|
|
worker.close()
|
|
return _response(request_id, result={"closed": True})
|
|
raise WorkerError("unsupported operation")
|
|
|
|
|
|
def _json_line(value: dict[str, Any]) -> bytes:
|
|
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8") + b"\n"
|
|
if len(raw) > MAX_OUTPUT_BYTES:
|
|
raise WorkerError("response exceeds protocol limit")
|
|
return raw
|
|
|
|
|
|
def run_stream(worker: BrowserWorker, reader: Any = None, writer: Any = None) -> int:
|
|
reader = reader or sys.stdin.buffer
|
|
writer = writer or sys.stdout.buffer
|
|
while True:
|
|
raw = reader.readline(MAX_LINE_BYTES + 1)
|
|
if not raw:
|
|
break
|
|
request: Any = None
|
|
if len(raw) > MAX_LINE_BYTES:
|
|
response = _response(0, error="request exceeds protocol limit")
|
|
writer.write(_json_line(response)); writer.flush()
|
|
return 1
|
|
try:
|
|
request = json.loads(raw.decode("utf-8"))
|
|
response = process_request(worker, request)
|
|
except Exception as exc:
|
|
request_id = request.get("id", 0) if isinstance(request, dict) else 0
|
|
safe_error = str(exc) if isinstance(exc, WorkerError) else "browser operation failed; no action was retried"
|
|
response = _response(max(0, request_id) if type(request_id) is int else 0,
|
|
error=safe_error[:MAX_STRING],
|
|
details=exc.details if isinstance(exc, BrowserOperationError) else None)
|
|
writer.write(_json_line(response)); writer.flush()
|
|
if isinstance(request, dict) and (request.get("op") == "close" or
|
|
isinstance(request.get("operation"), dict) and request["operation"].get("op") == "close"):
|
|
break
|
|
return 0
|
|
|
|
|
|
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(add_help=False)
|
|
parser.add_argument("--cdp-url", required=True)
|
|
parser.add_argument("--allowed-origins", required=True)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
class StartupFailureWorker:
|
|
"""Keep startup failures inside bounded IPC; never expose raw stderr."""
|
|
|
|
def __init__(self, message: str):
|
|
self.message = message
|
|
|
|
def observe(self) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def act(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def read(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def assert_page(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def navigate(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def reload(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def list_tabs(self) -> list[dict[str, Any]]:
|
|
raise WorkerError(self.message)
|
|
|
|
def select_tab(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def wait_popup(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def close_tab(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def capture_evidence(self, request: Any) -> dict[str, Any]:
|
|
raise WorkerError(self.message)
|
|
|
|
def narrow_origin_policy(self, request: Any) -> Any:
|
|
raise WorkerError(self.message)
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _parse_args(argv or sys.argv[1:])
|
|
worker: BrowserWorker | None = None
|
|
|
|
def shutdown(signum: int, _frame: Any) -> None:
|
|
# Also interrupts startup: RealBrowserBackend catches BaseException and
|
|
# tears down its owned daemon before this SystemExit can escape.
|
|
raise SystemExit(128 + signum)
|
|
|
|
signal.signal(signal.SIGTERM, shutdown)
|
|
signal.signal(signal.SIGINT, shutdown)
|
|
try:
|
|
cdp_url = validate_cdp_url(args.cdp_url)
|
|
origins = json.loads(args.allowed_origins)
|
|
normalized_origins = normalize_allowed_origins(origins)
|
|
session_id = os.environ.get("FAST_BROWSER_SESSION_ID") or f"session-{uuid.uuid4().hex}"
|
|
# Evidence bytes cross the bounded worker transport; the parent
|
|
# runtime commits them to its caller-owned PrivateArtifactStore.
|
|
worker = BrowserRuntimeWorker(
|
|
RealBrowserBackend(cdp_url, normalized_origins), normalized_origins,
|
|
session_id=session_id,
|
|
)
|
|
|
|
return run_stream(worker)
|
|
except Exception as exc:
|
|
detail = str(exc)[:MAX_STRING] if isinstance(exc, WorkerError) else "browser worker initialization failed"
|
|
if isinstance(exc, ModuleNotFoundError) and getattr(exc, "name", "") == "browser_harness":
|
|
detail = "install browser-harness in this Python environment or set FAST_BROWSER_PYTHON to its executable"
|
|
# The bridge deliberately discards raw stderr. Reply to the queued
|
|
# request with a safe failure instead of exiting before IPC starts.
|
|
return run_stream(StartupFailureWorker(detail))
|
|
finally:
|
|
if worker is not None:
|
|
worker.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|