"""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, replace 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_METADATA_BATCH = 16 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() batch_metadata = getattr(self.backend, "metadata_many", None) pending: list[tuple[int, str, str]] = [] def consume(items: list[tuple[int, str, str]]) -> None: if not items: return metadata_by_id: dict[int, ElementMetadata] = {} if callable(batch_metadata): try: result = batch_metadata([backend_id for backend_id, _role, _name in items]) except Exception: result = None if isinstance(result, dict): for backend_id, meta in result.items(): if type(backend_id) is int and isinstance(meta, ElementMetadata): metadata_by_id[backend_id] = meta for backend_id, ax_role, ax_name in items: meta = metadata_by_id.get(backend_id) if meta is None or meta.backend_id != backend_id: try: meta = self.backend.metadata(backend_id) except Exception: continue if not meta.connected or meta.backend_id != backend_id: continue # Full AX discovery is authoritative for observation roles and # click names. DOM metadata remains the only source for fill # labels, so a field's current value cannot enter an observation. observed_meta = replace(meta, ax_role=ax_role, ax_name=ax_name) candidate_role = (ax_role or observed_meta.role).lower() actions = _candidate_actions(observed_meta, candidate_role) if not actions: continue name = _safe_name( observed_meta.name if "fill" in actions else (ax_name or observed_meta.ax_name or observed_meta.name) ) candidate_id = f"c-{len(records)}" records.append(CandidateRecord( candidate_id, backend_id, candidate_role, name, actions, observed_meta )) if len(records) >= MAX_CANDIDATES: break 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 pending.append((backend_id, ax_role, ax_name)) if len(pending) >= MAX_METADATA_BATCH: consume(pending) pending.clear() consume(pending) 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.ax_name or current.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() _METADATA_FUNCTION = """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')) }; }""" _METADATA_MANY_FUNCTION = ( "function (...nodes) {\n" f" const read = {_METADATA_FUNCTION};\n" " return nodes.map(node => node && typeof node === 'object' ? read.call(node) : null);\n" "}" ) 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: # Keep the harness' exact readyState query, but avoid its coarse 300ms # sleep. This is only a readiness read; it never dispatches navigation. if timeout <= 0: return False deadline = time.monotonic() + timeout while True: if self._helpers.js("document.readyState") == "complete": return True remaining = deadline - time.monotonic() if remaining <= 0: return False time.sleep(min(0.025, remaining)) 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 @staticmethod def _metadata_from_dom_value(backend_id: int, value: Any) -> ElementMetadata: if not isinstance(value, dict): raise WorkerError("element metadata unavailable") 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)), ) def metadata(self, backend_id: int) -> ElementMetadata: value = self._call(backend_id, _METADATA_FUNCTION) metadata = self._metadata_from_dom_value(backend_id, value) 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 replace(metadata, ax_role=ax_role, ax_name=ax_name) def metadata_many(self, backend_ids: Iterable[int]) -> dict[int, ElementMetadata]: """Read value-free DOM metadata for one bounded observation chunk. The full AX tree is already the observation's source for role/name. This seam only batches the DOM extraction; action revalidation continues to use metadata(), including its fresh partial AX read. """ ids = [backend_id for backend_id in backend_ids if type(backend_id) is int and backend_id > 0][:MAX_METADATA_BATCH] if not ids: return {} object_group = f"fast-browser-metadata-{uuid.uuid4().hex}" object_ids: list[str | None] = [] try: for backend_id in ids: try: resolved = self._helpers.cdp( "DOM.resolveNode", backendNodeId=backend_id, objectGroup=object_group ) except Exception: object_ids.append(None) continue object_id = (resolved.get("object") or {}).get("objectId") if isinstance(resolved, dict) else None object_ids.append(object_id if isinstance(object_id, str) else None) target_object = next((object_id for object_id in object_ids if object_id is not None), None) if target_object is None: return {} arguments = [ {"objectId": object_id} if object_id is not None else {"value": None} for object_id in object_ids ] try: response = self._helpers.cdp( "Runtime.callFunctionOn", objectId=target_object, functionDeclaration=_METADATA_MANY_FUNCTION, arguments=arguments, returnByValue=True, awaitPromise=False, objectGroup=object_group, ) except Exception: return {} details = response.get("exceptionDetails") if isinstance(response, dict) else None remote = response.get("result") if isinstance(response, dict) else None values = remote.get("value") if isinstance(remote, dict) else None if details or not isinstance(remote, dict) or remote.get("subtype") == "error" or not isinstance(values, list): return {} result: dict[int, ElementMetadata] = {} for backend_id, value in zip(ids, values): try: result[backend_id] = self._metadata_from_dom_value(backend_id, value) except WorkerError: # Missing or malformed entries use the safe legacy path in # BrowserWorker.observe rather than being treated as absent. continue return result finally: try: self._helpers.cdp("Runtime.releaseObjectGroup", objectGroup=object_group) except Exception: pass 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 inventories until the target is gone or the existing action # budget expires. The close mutation itself is dispatched exactly once. deadline = time.monotonic() + ACTION_TIMEOUT_SECONDS while True: 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 not in self._target_to_tab: break remaining = deadline - time.monotonic() if remaining <= 0: raise self._fail("closeTab", "worker_failure", "verify", "tab close could not be verified", mutation_state="unknown") time.sleep(min(0.025, remaining)) 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())