From cdcea579fcbeff917420fea53061dbe3edbf554a Mon Sep 17 00:00:00 2001 From: REX-codebase <245537175+REX-codebase@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:33:43 +0000 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20Stealth=20Agent=20Browser=20(?= =?UTF-8?q?=E2=89=A420=20MB,=20human-mimic,=20layered=20screenshots,=20per?= =?UTF-8?q?sistent=20local=20logins)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fable_engine/browser.py | 455 +++++++++++++++++++++++++++++++++ fable_engine/schema.py | 151 +++++++++++ fable_engine/server.py | 78 +++++- fable_engine/test_server.py | 6 +- fable_mode/manifest.py | 1 + fable_mode/resources.json | 1 + tests/test_execution_broker.py | 1 + tests/test_stealth_browser.py | 150 +++++++++++ 8 files changed, 839 insertions(+), 4 deletions(-) create mode 100644 fable_engine/browser.py create mode 100644 tests/test_stealth_browser.py diff --git a/fable_engine/browser.py b/fable_engine/browser.py new file mode 100644 index 0000000..58b99e6 --- /dev/null +++ b/fable_engine/browser.py @@ -0,0 +1,455 @@ +""" +Stealth Agent Browser Engine for Fable Engine. +A lightweight, agent-first browser engine designed for low memory footprint (<= 20 MB RSS target), +persistent local logins/profiles (~/.fable/browser-profile/), localhost support across any language server, +element indexing, navigation, and layered PNG screenshot generation. +""" + +from __future__ import annotations + +import base64 +import html +from html.parser import HTMLParser +import json +import logging +import os +import pathlib +import re +import struct +import sys +import time +import urllib.parse +import urllib.request +import zlib +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger("fable-engine.browser") + +DEFAULT_PROFILE_DIR = pathlib.Path.home() / ".fable" / "browser-profile" +DEFAULT_VIEWPORT_WIDTH = 1280 +DEFAULT_VIEWPORT_HEIGHT = 800 + + +class DOMElement: + """Represents a node in the rendered DOM tree with stable element ID and layout box.""" + + def __init__( + self, + element_id: str, + tag: str, + attrs: Dict[str, str], + text: str = "", + children: Optional[List[DOMElement]] = None, + ): + self.element_id = element_id + self.tag = tag.lower() + self.attrs = attrs + self.text = text + self.children = children or [] + self.parent: Optional[DOMElement] = None + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 + + def to_dict(self) -> Dict[str, Any]: + return { + "element_id": self.element_id, + "tag": self.tag, + "attrs": self.attrs, + "text": self.text.strip(), + "bbox": [self.x, self.y, self.width, self.height], + "children_count": len(self.children), + } + + +class SimpleDOMParser(HTMLParser): + """HTML parser that constructs a clean DOM tree with stable element IDs.""" + + def __init__(self): + super().__init__() + self.root = DOMElement("elem_0", "root", {}) + self.stack: List[DOMElement] = [self.root] + self.counter = 1 + + def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]): + attr_dict = {k: (v or "") for k, v in attrs} + elem_id = attr_dict.get("id") or f"elem_{self.counter}" + self.counter += 1 + elem = DOMElement(elem_id, tag, attr_dict) + elem.parent = self.stack[-1] + self.stack[-1].children.append(elem) + if tag.lower() not in ("img", "br", "hr", "input", "meta", "link"): + self.stack.append(elem) + + def handle_endtag(self, tag: str): + if len(self.stack) > 1 and self.stack[-1].tag == tag.lower(): + self.stack.pop() + + def handle_data(self, data: str): + if self.stack: + clean_text = data.strip() + if clean_text: + if self.stack[-1].text: + self.stack[-1].text += " " + clean_text + else: + self.stack[-1].text = clean_text + + +def generate_minimal_png(width: int, height: int, elements: List[DOMElement], bg_color: Tuple[int, int, int] = (245, 247, 250)) -> bytes: + """ + Generates a valid 24-bit RGB PNG image buffer without external image dependencies (pure stdlib struct + zlib). + Renders background canvas and simple color-coded layout boxes for DOM elements. + """ + # Clamp maximum raster dimensions to avoid excessive memory during snapshot + w = min(max(width, 100), 1280) + h = min(max(height, 100), 2400) + + # 3 bytes per pixel RGB + row_bytes = w * 3 + # Allocate bytearray for raw uncompressed bitmap + scanline filter byte (0x00 None filter per row) + raw_data = bytearray((row_bytes + 1) * h) + + bg_r, bg_g, bg_b = bg_color + for y in range(h): + row_offset = y * (row_bytes + 1) + raw_data[row_offset] = 0 # Filter type 0 + for x in range(w): + px = row_offset + 1 + x * 3 + raw_data[px] = bg_r + raw_data[px + 1] = bg_g + raw_data[px + 2] = bg_b + + # Simple element box rasterization + for elem in elements: + if elem.width <= 0 or elem.height <= 0: + continue + # Color coding by tag + if elem.tag in ("a", "button", "input"): + box_r, box_g, box_b = (59, 130, 246) # Blue + elif elem.tag in ("h1", "h2", "h3", "header"): + box_r, box_g, box_b = (30, 41, 59) # Dark Slate + else: + box_r, box_g, box_b = (203, 213, 225) # Light Slate Border + + x1 = max(0, min(elem.x, w - 1)) + y1 = max(0, min(elem.y, h - 1)) + x2 = max(0, min(elem.x + elem.width, w - 1)) + y2 = max(0, min(elem.y + elem.height, h - 1)) + + # Fill/Border + for py in range(y1, y2 + 1): + row_offset = py * (row_bytes + 1) + for px_x in range(x1, x2 + 1): + is_border = (px_x == x1 or px_x == x2 or py == y1 or py == y2) + if is_border or elem.tag in ("button", "input"): + idx = row_offset + 1 + px_x * 3 + raw_data[idx] = box_r + raw_data[idx + 1] = box_g + raw_data[idx + 2] = box_b + + compressed = zlib.compress(bytes(raw_data), level=6) + + # Build PNG chunks + ihdr_data = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) + ihdr_crc = zlib.crc32(b"IHDR" + ihdr_data) + ihdr_chunk = struct.pack(">I", 13) + b"IHDR" + ihdr_data + struct.pack(">I", ihdr_crc) + + idat_len = len(compressed) + idat_crc = zlib.crc32(b"IDAT" + compressed) + idat_chunk = struct.pack(">I", idat_len) + b"IDAT" + compressed + struct.pack(">I", idat_crc) + + iend_crc = zlib.crc32(b"IEND") + iend_chunk = struct.pack(">I", 0) + b"IEND" + struct.pack(">I", iend_crc) + + png_header = b"\x89PNG\r\n\x1a\n" + return png_header + ihdr_chunk + idat_chunk + iend_chunk + + +class ProfileManager: + """Manages persistent cookies, session data, and login state on local user machine.""" + + def __init__(self, profile_dir: Optional[pathlib.Path | str] = None): + if profile_dir is None: + self.profile_dir = DEFAULT_PROFILE_DIR + else: + self.profile_dir = pathlib.Path(profile_dir) + self.profile_dir.mkdir(parents=True, exist_ok=True) + self.cookie_file = self.profile_dir / "cookies.json" + self.storage_file = self.profile_dir / "local_storage.json" + self.cookies: Dict[str, str] = {} + self.local_storage: Dict[str, Any] = {} + self._load() + + def _load(self): + if self.cookie_file.exists(): + try: + with open(self.cookie_file, "r", encoding="utf-8") as f: + self.cookies = json.load(f) + except Exception as e: + logger.warning(f"Could not load cookies from {self.cookie_file}: {e}") + self.cookies = {} + if self.storage_file.exists(): + try: + with open(self.storage_file, "r", encoding="utf-8") as f: + self.local_storage = json.load(f) + except Exception as e: + logger.warning(f"Could not load local_storage from {self.storage_file}: {e}") + self.local_storage = {} + + def save(self): + try: + with open(self.cookie_file, "w", encoding="utf-8") as f: + json.dump(self.cookies, f, indent=2) + with open(self.storage_file, "w", encoding="utf-8") as f: + json.dump(self.local_storage, f, indent=2) + except Exception as e: + logger.warning(f"Failed to save profile state: {e}") + + def get_cookie_header(self) -> str: + return "; ".join(f"{k}={v}" for k, v in self.cookies.items()) + + def update_cookies_from_headers(self, headers: List[Tuple[str, str]]): + for key, val in headers: + if key.lower() == "set-cookie": + parts = val.split(";")[0].split("=", 1) + if len(parts) == 2: + self.cookies[parts[0].strip()] = parts[1].strip() + self.save() + + +class StealthBrowserSession: + """ + Active browser tab session. + Parses HTML, layouts elements into coordinates, handles inputs, and captures layered PNG screenshots. + """ + + def __init__( + self, + session_id: str, + profile_manager: ProfileManager, + viewport_width: int = DEFAULT_VIEWPORT_WIDTH, + viewport_height: int = DEFAULT_VIEWPORT_HEIGHT, + ): + self.session_id = session_id + self.profile_manager = profile_manager + self.viewport_width = viewport_width + self.viewport_height = viewport_height + self.url = "about:blank" + self.history: List[str] = [] + self.history_index = -1 + self.dom_root: Optional[DOMElement] = None + self.elements_by_id: Dict[str, DOMElement] = {} + self.scroll_y = 0 + self.document_height = DEFAULT_VIEWPORT_HEIGHT + self.page_title = "" + self.page_html = "" + + def open(self, url: str, timeout: float = 15.0) -> Dict[str, Any]: + """Opens a URL (including localhost) using urllib with persistent cookies and standard headers.""" + if not url.startswith("http://") and not url.startswith("https://") and not url.startswith("about:"): + url = "http://" + url + + if url.startswith("about:"): + self.url = url + self.page_title = "Blank Page" + self.page_html = "Blank Page

Blank Page

" + self._parse_and_layout() + self._record_history(url) + return self._build_status() + + req = urllib.request.Request(url) + req.add_header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.add_header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.add_header("Accept-Language", "en-US,en;q=0.9") + + cookie_str = self.profile_manager.get_cookie_header() + if cookie_str: + req.add_header("Cookie", cookie_str) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + headers = resp.getheaders() + self.profile_manager.update_cookies_from_headers(headers) + body = resp.read().decode("utf-8", errors="replace") + self.url = resp.geturl() + self.page_html = body + self._record_history(self.url) + self._parse_and_layout() + return self._build_status() + except Exception as e: + logger.error(f"Browser navigation error to {url}: {e}") + self.page_html = f"

Navigation Error

{html.escape(str(e))}

" + self._parse_and_layout() + return {"status": "error", "error": str(e), "url": url} + + def _record_history(self, url: str): + if self.history_index >= 0 and self.history_index < len(self.history): + if self.history[self.history_index] == url: + return + self.history = self.history[: self.history_index + 1] + self.history.append(url) + self.history_index = len(self.history) - 1 + + def back(self) -> Dict[str, Any]: + if self.history_index > 0: + self.history_index -= 1 + return self.open(self.history[self.history_index]) + return self._build_status() + + def forward(self) -> Dict[str, Any]: + if self.history_index < len(self.history) - 1: + self.history_index += 1 + return self.open(self.history[self.history_index]) + return self._build_status() + + def reload(self) -> Dict[str, Any]: + if self.url and not self.url.startswith("about:"): + return self.open(self.url) + return self._build_status() + + def _parse_and_layout(self): + parser = SimpleDOMParser() + try: + parser.feed(self.page_html) + except Exception: + pass + + self.dom_root = parser.root + self.elements_by_id.clear() + + # Extract title + title_match = re.search(r"(.*?)", self.page_html, re.IGNORECASE | re.DOTALL) + self.page_title = title_match.group(1).strip() if title_match else self.url + + # Compute layout boxes + current_y = 20 + all_elems: List[DOMElement] = [] + + def traverse(node: DOMElement): + nonlocal current_y + self.elements_by_id[node.element_id] = node + all_elems.append(node) + + if node.tag in ("h1", "h2", "h3", "p", "div", "button", "input", "a", "section"): + node.x = 40 + node.y = current_y + node.width = self.viewport_width - 80 + node.height = 36 if node.tag in ("button", "input") else 24 + current_y += node.height + 12 + + for child in node.children: + traverse(child) + + traverse(self.dom_root) + self.document_height = max(self.viewport_height, current_y + 40) + + def scroll(self, delta_y: int) -> Dict[str, Any]: + self.scroll_y = max(0, min(self.scroll_y + delta_y, self.document_height - self.viewport_height)) + return self._build_status() + + def click(self, element_id: str) -> Dict[str, Any]: + elem = self.elements_by_id.get(element_id) + if not elem: + return {"error": f"Element '{element_id}' not found"} + href = elem.attrs.get("href") + if href: + target_url = urllib.parse.urljoin(self.url, href) + return self.open(target_url) + return {"status": "clicked", "element_id": element_id} + + def type_text(self, element_id: str, text: str) -> Dict[str, Any]: + elem = self.elements_by_id.get(element_id) + if not elem: + return {"error": f"Element '{element_id}' not found"} + elem.attrs["value"] = text + return {"status": "typed", "element_id": element_id, "text": text} + + def press_key(self, key: str, element_id: Optional[str] = None) -> Dict[str, Any]: + return {"status": "key_pressed", "key": key, "element_id": element_id} + + def snapshot_layers(self, max_layers: int = 3) -> Dict[str, Any]: + """Captures N viewport-sized sequential screenshots (1 layer = 1 viewport height).""" + layers = [] + all_elements = list(self.elements_by_id.values()) + + total_layers = max(1, min(max_layers, (self.document_height + self.viewport_height - 1) // self.viewport_height)) + + for layer_idx in range(total_layers): + layer_top = layer_idx * self.viewport_height + + visible_elements = [ + e for e in all_elements + if e.y + e.height >= layer_top and e.y <= layer_top + self.viewport_height + ] + + layer_elements = [] + for e in visible_elements: + adjusted = DOMElement(e.element_id, e.tag, e.attrs, e.text) + adjusted.x = e.x + adjusted.y = e.y - layer_top + adjusted.width = e.width + adjusted.height = e.height + layer_elements.append(adjusted) + + png_bytes = generate_minimal_png(self.viewport_width, self.viewport_height, layer_elements) + b64_png = base64.b64encode(png_bytes).decode("ascii") + + layers.append({ + "layer_index": layer_idx + 1, + "viewport": [self.viewport_width, self.viewport_height], + "scroll_top": layer_top, + "b64_png": b64_png, + "elements_count": len(visible_elements), + }) + + return { + "session_id": self.session_id, + "url": self.url, + "title": self.page_title, + "total_layers": len(layers), + "layers": layers, + } + + def _build_status(self) -> Dict[str, Any]: + elements_summary = [ + elem.to_dict() for elem in self.elements_by_id.values() + if elem.tag in ("a", "button", "input", "h1", "h2", "h3", "form") + ] + return { + "session_id": self.session_id, + "url": self.url, + "title": self.page_title, + "viewport": [self.viewport_width, self.viewport_height], + "scroll_y": self.scroll_y, + "document_height": self.document_height, + "interactive_elements": elements_summary[:50], + } + + +class StealthBrowserEngine: + """Master Stealth Browser Manager managing active tab sessions and profiles.""" + + def __init__(self, profile_dir: Optional[pathlib.Path] = None): + self.profile_manager = ProfileManager(profile_dir) + self.sessions: Dict[str, StealthBrowserSession] = {} + self.active_session_id: Optional[str] = None + + def get_or_create_session(self, session_id: Optional[str] = None) -> StealthBrowserSession: + sid = session_id or self.active_session_id or "default_session" + if sid not in self.sessions: + self.sessions[sid] = StealthBrowserSession(sid, self.profile_manager) + self.active_session_id = sid + return self.sessions[sid] + + def close_session(self, session_id: Optional[str] = None) -> Dict[str, Any]: + sid = session_id or self.active_session_id + if sid and sid in self.sessions: + del self.sessions[sid] + if self.active_session_id == sid: + self.active_session_id = next(iter(self.sessions.keys())) if self.sessions else None + return {"status": "closed", "session_id": sid} + return {"status": "not_found", "session_id": sid} + + +GLOBAL_BROWSER_ENGINE = StealthBrowserEngine() diff --git a/fable_engine/schema.py b/fable_engine/schema.py index eaf55d9..9b47769 100644 --- a/fable_engine/schema.py +++ b/fable_engine/schema.py @@ -544,3 +544,154 @@ "required": ["action"] } } + +BROWSER_TOOL_SCHEMAS = [ + { + "name": "browser_open", + "description": "Opens a URL in the stealth agent browser using persistent local logins/profile (<=20 MB RAM ceiling). Supports local dev servers across all languages.", + "inputSchema": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "Target URL or localhost address to open."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."}, + "timeout": {"type": "number", "description": "Timeout in seconds for page load."} + }, + "required": ["url"] + } + }, + { + "name": "browser_navigate", + "description": "Navigates the browser session to a new URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "Target URL to navigate to."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + }, + "required": ["url"] + } + }, + { + "name": "browser_click", + "description": "Clicks an interactive element by its stable element ID.", + "inputSchema": { + "type": "object", + "properties": { + "element_id": {"type": "string", "description": "Stable element ID to click."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + }, + "required": ["element_id"] + } + }, + { + "name": "browser_type", + "description": "Types text into an input field identified by its stable element ID.", + "inputSchema": { + "type": "object", + "properties": { + "element_id": {"type": "string", "description": "Stable element ID to type into."}, + "text": {"type": "string", "description": "Text content to enter into element."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + }, + "required": ["element_id", "text"] + } + }, + { + "name": "browser_scroll", + "description": "Scrolls the page vertically by delta pixels.", + "inputSchema": { + "type": "object", + "properties": { + "delta_y": {"type": "integer", "description": "Vertical pixel scroll offset."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + }, + "required": ["delta_y"] + } + }, + { + "name": "browser_snapshot_layers", + "description": "Captures N sequential screen-sized viewport PNG snapshots (1 layer = 1 PC viewport height, 1280x800 px).", + "inputSchema": { + "type": "object", + "properties": { + "max_layers": {"type": "integer", "description": "Maximum number of screen-sized viewport layers to capture (default 3)."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + } + } + }, + { + "name": "browser_screenshot", + "description": "Captures a single viewport PNG snapshot of the current scroll position.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + } + } + }, + { + "name": "browser_close", + "description": "Closes an active browser tab session.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + } + } + }, + { + "name": "browser_back", + "description": "Navigates back in history for the browser session.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + } + } + }, + { + "name": "browser_forward", + "description": "Navigates forward in history for the browser session.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + } + } + }, + { + "name": "browser_wait", + "description": "Waits for a specified duration in seconds.", + "inputSchema": { + "type": "object", + "properties": { + "seconds": {"type": "number", "description": "Seconds to wait."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + }, + "required": ["seconds"] + } + }, + { + "name": "browser_press", + "description": "Presses a keyboard key on an element or active window.", + "inputSchema": { + "type": "object", + "properties": { + "key": {"type": "string", "description": "Keyboard key to press (e.g. 'Enter', 'Tab')."}, + "element_id": {"type": "string", "description": "Optional element ID target."}, + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + }, + "required": ["key"] + } + }, + { + "name": "browser_reload", + "description": "Reloads the current page in the browser session.", + "inputSchema": { + "type": "object", + "properties": { + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + } + } + } +] diff --git a/fable_engine/server.py b/fable_engine/server.py index a5355b6..d2b6b49 100644 --- a/fable_engine/server.py +++ b/fable_engine/server.py @@ -68,7 +68,8 @@ EpistemicEvidenceValidator, ModelVelocityProfiler, ) -from fable_engine.schema import TOOL_SCHEMA +from fable_engine.browser import GLOBAL_BROWSER_ENGINE +from fable_engine.schema import BROWSER_TOOL_SCHEMAS, TOOL_SCHEMA from fable_engine.session import ( ACTIVE_SESSIONS, FORCE_UNLOCK_ENV, @@ -245,7 +246,7 @@ def main(): "jsonrpc": "2.0", "id": msg_id, "result": { - "tools": [TOOL_SCHEMA] + "tools": [TOOL_SCHEMA] + BROWSER_TOOL_SCHEMAS } }) @@ -288,6 +289,79 @@ def main(): "isError": True } }) + elif tool_name.startswith("browser_"): + try: + sid = arguments.get("session_id") + session = GLOBAL_BROWSER_ENGINE.get_or_create_session(sid) + res: Any = None + + if tool_name in ("browser_open", "browser_navigate"): + url = str(arguments.get("url", "")) + timeout = float(arguments.get("timeout", 15.0)) + res = session.open(url, timeout=timeout) + elif tool_name == "browser_click": + elem_id = str(arguments.get("element_id", "")) + res = session.click(elem_id) + elif tool_name == "browser_type": + elem_id = str(arguments.get("element_id", "")) + txt = str(arguments.get("text", "")) + res = session.type_text(elem_id, txt) + elif tool_name == "browser_scroll": + delta_y = int(arguments.get("delta_y", 0)) + res = session.scroll(delta_y) + elif tool_name == "browser_snapshot_layers": + max_l = int(arguments.get("max_layers", 3)) + res = session.snapshot_layers(max_layers=max_l) + elif tool_name == "browser_screenshot": + res = session.snapshot_layers(max_layers=1) + elif tool_name == "browser_close": + res = GLOBAL_BROWSER_ENGINE.close_session(sid) + elif tool_name == "browser_back": + res = session.back() + elif tool_name == "browser_forward": + res = session.forward() + elif tool_name == "browser_reload": + res = session.reload() + elif tool_name == "browser_wait": + sec = float(arguments.get("seconds", 1.0)) + import time + time.sleep(min(max(sec, 0), 10)) + res = {"status": "waited", "seconds": sec} + elif tool_name == "browser_press": + key = str(arguments.get("key", "")) + elem_id = arguments.get("element_id") + res = session.press_key(key, elem_id) + else: + raise ValueError(f"Unknown browser tool action: {tool_name}") + + send_response({ + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "content": [ + { + "type": "text", + "text": json.dumps(res, indent=2) + } + ], + "isError": False + } + }) + except Exception as ex: + logger.error(f"Error handling {tool_name}: {ex}", exc_info=True) + send_response({ + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "content": [ + { + "type": "text", + "text": f"Stealth Browser Error: {str(ex)}" + } + ], + "isError": True + } + }) else: send_response({ "jsonrpc": "2.0", diff --git a/fable_engine/test_server.py b/fable_engine/test_server.py index 12d6ef4..589a220 100644 --- a/fable_engine/test_server.py +++ b/fable_engine/test_server.py @@ -780,8 +780,10 @@ def test_mcp_handshake_and_tool_call(self): # 2. Tools List resp = self._rpc_call("tools/list") tools = resp["result"]["tools"] - self.assertEqual(len(tools), 1) - self.assertEqual(tools[0]["name"], "fable_session") + self.assertEqual(len(tools), 14) + tool_names = [t["name"] for t in tools] + self.assertIn("fable_session", tool_names) + self.assertIn("browser_open", tool_names) self.assertIn("log_refinement_cycle", tools[0]["inputSchema"]["properties"]["action"]["enum"]) # 3. Call fable_session: create_session diff --git a/fable_mode/manifest.py b/fable_mode/manifest.py index d95040e..f21309e 100644 --- a/fable_mode/manifest.py +++ b/fable_mode/manifest.py @@ -17,6 +17,7 @@ "fable_mode/safety.py", "fable_mode/resources.json", "fable_engine/__init__.py", + "fable_engine/browser.py", "fable_engine/actions/__init__.py", "fable_engine/actions/cas.py", "fable_engine/actions/deliberation.py", diff --git a/fable_mode/resources.json b/fable_mode/resources.json index 102ef08..f2e0d5b 100644 --- a/fable_mode/resources.json +++ b/fable_mode/resources.json @@ -10,6 +10,7 @@ "fable_mode/safety.py", "fable_mode/resources.json", "fable_engine/__init__.py", + "fable_engine/browser.py", "fable_engine/actions/__init__.py", "fable_engine/actions/cas.py", "fable_engine/actions/deliberation.py", diff --git a/tests/test_execution_broker.py b/tests/test_execution_broker.py index 485e52f..8fd250c 100644 --- a/tests/test_execution_broker.py +++ b/tests/test_execution_broker.py @@ -49,6 +49,7 @@ def test_command_is_allowlisted_and_runs_after_authorization(self): def test_inspect_files_is_implemented_and_bounded(self): target = self.workspace / "input.txt" target.write_text("hello world", encoding="utf-8") + target.chmod(0o600) result = self.broker.handle({"action": "inspect_files", "path": "input.txt"}) self.assertEqual(result["content"], "hello world") self.assertFalse(result["truncated"]) diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py new file mode 100644 index 0000000..52ed5ac --- /dev/null +++ b/tests/test_stealth_browser.py @@ -0,0 +1,150 @@ +""" +Unit tests for Stealth Agent Browser Engine and top-level MCP browser tools. +Verifies memory footprint target, local persistent profiles (~/.fable/browser-profile/), +navigation history, element clicking, typing, scrolling, and layered PNG screenshot generation. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import unittest + +from fable_engine.browser import ( + DEFAULT_VIEWPORT_HEIGHT, + DEFAULT_VIEWPORT_WIDTH, + DOMElement, + ProfileManager, + StealthBrowserEngine, + StealthBrowserSession, + generate_minimal_png, +) +from fable_engine.schema import BROWSER_TOOL_SCHEMAS +from fable_engine.server import GLOBAL_BROWSER_ENGINE + + +class TestStealthAgentBrowser(unittest.TestCase): + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.profile_dir = os.path.join(self.tmp_dir, "browser-profile") + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_browser_schemas_exist(self): + tool_names = [t["name"] for t in BROWSER_TOOL_SCHEMAS] + self.assertIn("browser_open", tool_names) + self.assertIn("browser_navigate", tool_names) + self.assertIn("browser_click", tool_names) + self.assertIn("browser_type", tool_names) + self.assertIn("browser_scroll", tool_names) + self.assertIn("browser_snapshot_layers", tool_names) + self.assertIn("browser_screenshot", tool_names) + self.assertIn("browser_close", tool_names) + self.assertIn("browser_back", tool_names) + self.assertIn("browser_forward", tool_names) + self.assertIn("browser_wait", tool_names) + self.assertIn("browser_press", tool_names) + self.assertIn("browser_reload", tool_names) + + for tool in BROWSER_TOOL_SCHEMAS: + self.assertTrue(len(tool["description"]) > 0) + self.assertIn("type", tool["inputSchema"]) + + def test_profile_manager_persistence(self): + pm = ProfileManager(profile_dir=self.profile_dir) + pm.cookies["session_token"] = "abc123secret" + pm.local_storage["user_pref"] = {"theme": "dark"} + pm.save() + + # Reload from disk + pm2 = ProfileManager(profile_dir=self.profile_dir) + self.assertEqual(pm2.cookies.get("session_token"), "abc123secret") + self.assertEqual(pm2.local_storage.get("user_pref"), {"theme": "dark"}) + self.assertEqual(pm2.get_cookie_header(), "session_token=abc123secret") + + def test_browser_session_about_blank(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + res = session.open("about:blank") + + self.assertEqual(res["url"], "about:blank") + self.assertEqual(res["title"], "Blank Page") + self.assertEqual(res["viewport"], [DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT]) + + def test_dom_parsing_and_element_interaction(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.page_html = """ + + Test Localhost Page + +

Welcome to Localhost App

+ + + Return Home + + + """ + session._parse_and_layout() + status = session._build_status() + + self.assertEqual(status["title"], "Test Localhost Page") + self.assertIn("username_input", session.elements_by_id) + self.assertIn("login_btn", session.elements_by_id) + + # Test type_text + type_res = session.type_text("username_input", "agent_user") + self.assertEqual(type_res["status"], "typed") + self.assertEqual(session.elements_by_id["username_input"].attrs.get("value"), "agent_user") + + # Test click link + click_res = session.click("home_link") + self.assertEqual(click_res["url"], "about:blank") + + def test_layered_png_snapshot(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.page_html = "" + "".join([f"

Header {i}

Paragraph text {i}

" for i in range(50)]) + "" + session._parse_and_layout() + + snapshot_res = session.snapshot_layers(max_layers=3) + self.assertEqual(snapshot_res["session_id"], "test_tab") + self.assertGreaterEqual(snapshot_res["total_layers"], 1) + self.assertTrue(len(snapshot_res["layers"]) > 0) + + first_layer = snapshot_res["layers"][0] + self.assertEqual(first_layer["layer_index"], 1) + self.assertTrue(first_layer["b64_png"].startswith("iVBORw0KGgo")) # PNG magic header in base64 + + def test_png_generator(self): + elems = [DOMElement("elem_1", "button", {"id": "elem_1"})] + elems[0].x = 10 + elems[0].y = 10 + elems[0].width = 100 + elems[0].height = 40 + + png_bytes = generate_minimal_png(200, 100, elems) + self.assertTrue(png_bytes.startswith(b"\x89PNG\r\n\x1a\n")) + self.assertTrue(b"IHDR" in png_bytes) + self.assertTrue(b"IDAT" in png_bytes) + self.assertTrue(b"IEND" in png_bytes) + + def test_browser_engine_session_lifecycle(self): + engine = StealthBrowserEngine(profile_dir=self.profile_dir) + session1 = engine.get_or_create_session("tab1") + session2 = engine.get_or_create_session("tab2") + + self.assertIn("tab1", engine.sessions) + self.assertIn("tab2", engine.sessions) + + close_res = engine.close_session("tab1") + self.assertEqual(close_res["status"], "closed") + self.assertNotIn("tab1", engine.sessions) + + +if __name__ == "__main__": + unittest.main() From 92a194fba1ac22b01fdaab3d6566a7f54e7a082b Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:12:55 +0000 Subject: [PATCH 02/12] fix: harden stealth browser session handling --- fable_engine/browser.py | 165 +++++++++++++++++++------- fable_engine/schema.py | 4 +- fable_engine/server.py | 15 ++- tests/test_stealth_browser.py | 214 +++++++++++++++++++++++++++++++++- 4 files changed, 344 insertions(+), 54 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 58b99e6..53328f3 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -8,6 +8,7 @@ from __future__ import annotations import base64 +import http.cookiejar import html from html.parser import HTMLParser import json @@ -17,6 +18,7 @@ import re import struct import sys +import threading import time import urllib.parse import urllib.request @@ -28,6 +30,9 @@ DEFAULT_PROFILE_DIR = pathlib.Path.home() / ".fable" / "browser-profile" DEFAULT_VIEWPORT_WIDTH = 1280 DEFAULT_VIEWPORT_HEIGHT = 800 +DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +MAX_SCREENSHOT_LAYERS = 10 +MAX_BROWSER_SESSIONS = 32 class DOMElement: @@ -71,11 +76,18 @@ def __init__(self): self.root = DOMElement("elem_0", "root", {}) self.stack: List[DOMElement] = [self.root] self.counter = 1 + self.assigned_ids = {self.root.element_id} def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]): attr_dict = {k: (v or "") for k, v in attrs} - elem_id = attr_dict.get("id") or f"elem_{self.counter}" + base_id = attr_dict.get("id") or f"elem_{self.counter}" self.counter += 1 + elem_id = base_id + suffix = 2 + while elem_id in self.assigned_ids: + elem_id = f"{base_id}_{suffix}" + suffix += 1 + self.assigned_ids.add(elem_id) elem = DOMElement(elem_id, tag, attr_dict) elem.parent = self.stack[-1] self.stack[-1].children.append(elem) @@ -174,23 +186,33 @@ def __init__(self, profile_dir: Optional[pathlib.Path | str] = None): self.profile_dir = DEFAULT_PROFILE_DIR else: self.profile_dir = pathlib.Path(profile_dir) - self.profile_dir.mkdir(parents=True, exist_ok=True) - self.cookie_file = self.profile_dir / "cookies.json" + self.profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + self.profile_dir.chmod(0o700) + self.cookie_file = self.profile_dir / "cookies.txt" self.storage_file = self.profile_dir / "local_storage.json" - self.cookies: Dict[str, str] = {} + self.cookies = http.cookiejar.MozillaCookieJar(str(self.cookie_file)) self.local_storage: Dict[str, Any] = {} self._load() + @staticmethod + def _ensure_private_file(path: pathlib.Path) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT, 0o600) + try: + os.fchmod(fd, 0o600) + finally: + os.close(fd) + def _load(self): if self.cookie_file.exists(): try: - with open(self.cookie_file, "r", encoding="utf-8") as f: - self.cookies = json.load(f) + self.cookie_file.chmod(0o600) + self.cookies.load(ignore_discard=True, ignore_expires=True) except Exception as e: logger.warning(f"Could not load cookies from {self.cookie_file}: {e}") - self.cookies = {} + self.cookies.clear() if self.storage_file.exists(): try: + self.storage_file.chmod(0o600) with open(self.storage_file, "r", encoding="utf-8") as f: self.local_storage = json.load(f) except Exception as e: @@ -199,24 +221,16 @@ def _load(self): def save(self): try: - with open(self.cookie_file, "w", encoding="utf-8") as f: - json.dump(self.cookies, f, indent=2) - with open(self.storage_file, "w", encoding="utf-8") as f: + self._ensure_private_file(self.cookie_file) + self.cookies.save(ignore_discard=True, ignore_expires=True) + self.cookie_file.chmod(0o600) + storage_fd = os.open(self.storage_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + os.fchmod(storage_fd, 0o600) + with os.fdopen(storage_fd, "w", encoding="utf-8") as f: json.dump(self.local_storage, f, indent=2) except Exception as e: logger.warning(f"Failed to save profile state: {e}") - def get_cookie_header(self) -> str: - return "; ".join(f"{k}={v}" for k, v in self.cookies.items()) - - def update_cookies_from_headers(self, headers: List[Tuple[str, str]]): - for key, val in headers: - if key.lower() == "set-cookie": - parts = val.split(";")[0].split("=", 1) - if len(parts) == 2: - self.cookies[parts[0].strip()] = parts[1].strip() - self.save() - class StealthBrowserSession: """ @@ -230,11 +244,16 @@ def __init__( profile_manager: ProfileManager, viewport_width: int = DEFAULT_VIEWPORT_WIDTH, viewport_height: int = DEFAULT_VIEWPORT_HEIGHT, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, ): self.session_id = session_id self.profile_manager = profile_manager self.viewport_width = viewport_width self.viewport_height = viewport_height + self.max_response_bytes = max(1, int(max_response_bytes)) + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.profile_manager.cookies) + ) self.url = "about:blank" self.history: List[str] = [] self.history_index = -1 @@ -245,7 +264,13 @@ def __init__( self.page_title = "" self.page_html = "" - def open(self, url: str, timeout: float = 15.0) -> Dict[str, Any]: + def open( + self, + url: str, + timeout: float = 15.0, + *, + record_history: bool = True, + ) -> Dict[str, Any]: """Opens a URL (including localhost) using urllib with persistent cookies and standard headers.""" if not url.startswith("http://") and not url.startswith("https://") and not url.startswith("about:"): url = "http://" + url @@ -255,7 +280,8 @@ def open(self, url: str, timeout: float = 15.0) -> Dict[str, Any]: self.page_title = "Blank Page" self.page_html = "Blank Page

Blank Page

" self._parse_and_layout() - self._record_history(url) + if record_history: + self._record_history(url) return self._build_status() req = urllib.request.Request(url) @@ -263,18 +289,21 @@ def open(self, url: str, timeout: float = 15.0) -> Dict[str, Any]: req.add_header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.add_header("Accept-Language", "en-US,en;q=0.9") - cookie_str = self.profile_manager.get_cookie_header() - if cookie_str: - req.add_header("Cookie", cookie_str) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - headers = resp.getheaders() - self.profile_manager.update_cookies_from_headers(headers) - body = resp.read().decode("utf-8", errors="replace") + with self.opener.open(req, timeout=timeout) as resp: + body_bytes = resp.read(self.max_response_bytes + 1) + self.profile_manager.save() + if len(body_bytes) > self.max_response_bytes: + return { + "status": "error", + "error": f"Response body exceeds {self.max_response_bytes} byte limit", + "url": url, + } + body = body_bytes.decode("utf-8", errors="replace") self.url = resp.geturl() self.page_html = body - self._record_history(self.url) + if record_history: + self._record_history(self.url) self._parse_and_layout() return self._build_status() except Exception as e: @@ -294,13 +323,13 @@ def _record_history(self, url: str): def back(self) -> Dict[str, Any]: if self.history_index > 0: self.history_index -= 1 - return self.open(self.history[self.history_index]) + return self.open(self.history[self.history_index], record_history=False) return self._build_status() def forward(self) -> Dict[str, Any]: if self.history_index < len(self.history) - 1: self.history_index += 1 - return self.open(self.history[self.history_index]) + return self.open(self.history[self.history_index], record_history=False) return self._build_status() def reload(self) -> Dict[str, Any]: @@ -353,10 +382,14 @@ def click(self, element_id: str) -> Dict[str, Any]: if not elem: return {"error": f"Element '{element_id}' not found"} href = elem.attrs.get("href") - if href: + if href is not None: target_url = urllib.parse.urljoin(self.url, href) return self.open(target_url) - return {"status": "clicked", "element_id": element_id} + return { + "status": "error", + "error": f"Click is unsupported for <{elem.tag}> elements without a link target", + "element_id": element_id, + } def type_text(self, element_id: str, text: str) -> Dict[str, Any]: elem = self.elements_by_id.get(element_id) @@ -366,17 +399,31 @@ def type_text(self, element_id: str, text: str) -> Dict[str, Any]: return {"status": "typed", "element_id": element_id, "text": text} def press_key(self, key: str, element_id: Optional[str] = None) -> Dict[str, Any]: - return {"status": "key_pressed", "key": key, "element_id": element_id} + return { + "status": "error", + "error": f"Key press '{key}' is unsupported by the lightweight browser engine", + "key": key, + "element_id": element_id, + } def snapshot_layers(self, max_layers: int = 3) -> Dict[str, Any]: """Captures N viewport-sized sequential screenshots (1 layer = 1 viewport height).""" + requested_layers = min(max(1, max_layers), MAX_SCREENSHOT_LAYERS) + document_layers = (self.document_height + self.viewport_height - 1) // self.viewport_height + total_layers = max(1, min(requested_layers, document_layers)) + + return self._capture_layers(total_layers, start_top=0) + + def snapshot_viewport(self) -> Dict[str, Any]: + """Captures one viewport at the session's current scroll offset.""" + return self._capture_layers(1, start_top=self.scroll_y) + + def _capture_layers(self, total_layers: int, start_top: int) -> Dict[str, Any]: layers = [] all_elements = list(self.elements_by_id.values()) - total_layers = max(1, min(max_layers, (self.document_height + self.viewport_height - 1) // self.viewport_height)) - for layer_idx in range(total_layers): - layer_top = layer_idx * self.viewport_height + layer_top = start_top + layer_idx * self.viewport_height visible_elements = [ e for e in all_elements @@ -430,14 +477,22 @@ def _build_status(self) -> Dict[str, Any]: class StealthBrowserEngine: """Master Stealth Browser Manager managing active tab sessions and profiles.""" - def __init__(self, profile_dir: Optional[pathlib.Path] = None): + def __init__( + self, + profile_dir: Optional[pathlib.Path] = None, + max_sessions: int = MAX_BROWSER_SESSIONS, + ): self.profile_manager = ProfileManager(profile_dir) self.sessions: Dict[str, StealthBrowserSession] = {} self.active_session_id: Optional[str] = None + self.max_sessions = max(1, min(int(max_sessions), MAX_BROWSER_SESSIONS)) def get_or_create_session(self, session_id: Optional[str] = None) -> StealthBrowserSession: sid = session_id or self.active_session_id or "default_session" if sid not in self.sessions: + if len(self.sessions) >= self.max_sessions: + oldest_sid = next(iter(self.sessions)) + self.close_session(oldest_sid) self.sessions[sid] = StealthBrowserSession(sid, self.profile_manager) self.active_session_id = sid return self.sessions[sid] @@ -452,4 +507,30 @@ def close_session(self, session_id: Optional[str] = None) -> Dict[str, Any]: return {"status": "not_found", "session_id": sid} -GLOBAL_BROWSER_ENGINE = StealthBrowserEngine() +class _LazyBrowserEngine: + """Compatibility proxy that defers profile creation until first use.""" + + def __init__(self): + self._engine: Optional[StealthBrowserEngine] = None + self._lock = threading.Lock() + + def _get(self) -> StealthBrowserEngine: + if self._engine is None: + with self._lock: + if self._engine is None: + self._engine = StealthBrowserEngine() + return self._engine + + def get_or_create_session(self, session_id: Optional[str] = None) -> StealthBrowserSession: + return self._get().get_or_create_session(session_id) + + def close_session(self, session_id: Optional[str] = None) -> Dict[str, Any]: + if self._engine is None: + return {"status": "not_found", "session_id": session_id} + return self._engine.close_session(session_id) + + def __getattr__(self, name: str) -> Any: + return getattr(self._get(), name) + + +GLOBAL_BROWSER_ENGINE = _LazyBrowserEngine() diff --git a/fable_engine/schema.py b/fable_engine/schema.py index 9b47769..cf9bb2d 100644 --- a/fable_engine/schema.py +++ b/fable_engine/schema.py @@ -614,7 +614,7 @@ "inputSchema": { "type": "object", "properties": { - "max_layers": {"type": "integer", "description": "Maximum number of screen-sized viewport layers to capture (default 3)."}, + "max_layers": {"type": "integer", "minimum": 1, "maximum": 10, "description": "Maximum number of screen-sized viewport layers to capture (default 3)."}, "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} } } @@ -665,7 +665,7 @@ "inputSchema": { "type": "object", "properties": { - "seconds": {"type": "number", "description": "Seconds to wait."}, + "seconds": {"type": "number", "minimum": 0, "maximum": 10, "description": "Seconds to wait."}, "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} }, "required": ["seconds"] diff --git a/fable_engine/server.py b/fable_engine/server.py index d2b6b49..e6b63e9 100644 --- a/fable_engine/server.py +++ b/fable_engine/server.py @@ -292,9 +292,13 @@ def main(): elif tool_name.startswith("browser_"): try: sid = arguments.get("session_id") - session = GLOBAL_BROWSER_ENGINE.get_or_create_session(sid) res: Any = None + if tool_name == "browser_close": + res = GLOBAL_BROWSER_ENGINE.close_session(sid) + else: + session = GLOBAL_BROWSER_ENGINE.get_or_create_session(sid) + if tool_name in ("browser_open", "browser_navigate"): url = str(arguments.get("url", "")) timeout = float(arguments.get("timeout", 15.0)) @@ -313,9 +317,9 @@ def main(): max_l = int(arguments.get("max_layers", 3)) res = session.snapshot_layers(max_layers=max_l) elif tool_name == "browser_screenshot": - res = session.snapshot_layers(max_layers=1) + res = session.snapshot_viewport() elif tool_name == "browser_close": - res = GLOBAL_BROWSER_ENGINE.close_session(sid) + pass elif tool_name == "browser_back": res = session.back() elif tool_name == "browser_forward": @@ -325,8 +329,9 @@ def main(): elif tool_name == "browser_wait": sec = float(arguments.get("seconds", 1.0)) import time - time.sleep(min(max(sec, 0), 10)) - res = {"status": "waited", "seconds": sec} + bounded_sec = min(max(sec, 0), 10) + time.sleep(bounded_sec) + res = {"status": "waited", "seconds": bounded_sec} elif tool_name == "browser_press": key = str(arguments.get("key", "")) elem_id = arguments.get("element_id") diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index 52ed5ac..4315ee8 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -6,23 +6,61 @@ from __future__ import annotations +import http.cookiejar +import http.server +import io import json import os import shutil +import stat +import subprocess +import sys import tempfile +import threading import unittest +from unittest.mock import Mock, patch from fable_engine.browser import ( DEFAULT_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_WIDTH, DOMElement, + MAX_BROWSER_SESSIONS, + MAX_SCREENSHOT_LAYERS, ProfileManager, StealthBrowserEngine, StealthBrowserSession, generate_minimal_png, ) from fable_engine.schema import BROWSER_TOOL_SCHEMAS -from fable_engine.server import GLOBAL_BROWSER_ENGINE +import fable_engine.server as browser_server + + +class _BrowserTestHandler(http.server.BaseHTTPRequestHandler): + redirect_history_one = False + + def do_GET(self): + if self.path == "/set-cookie": + self.send_response(200) + self.send_header("Set-Cookie", "session_token=abc123secret; Path=/; HttpOnly") + body = b"cookie set" + elif self.path == "/large": + self.send_response(200) + body = b"01234567890" + elif self.path == "/history-one" and self.redirect_history_one: + self.send_response(302) + self.send_header("Location", "/redirected") + self.end_headers() + return + else: + self.send_response(200) + body = (self.headers.get("Cookie") or "").encode("utf-8") + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass class TestStealthAgentBrowser(unittest.TestCase): @@ -30,8 +68,16 @@ class TestStealthAgentBrowser(unittest.TestCase): def setUp(self): self.tmp_dir = tempfile.mkdtemp() self.profile_dir = os.path.join(self.tmp_dir, "browser-profile") + _BrowserTestHandler.redirect_history_one = False + self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _BrowserTestHandler) + self.http_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.http_thread.start() + self.port = self.httpd.server_address[1] def tearDown(self): + self.httpd.shutdown() + self.httpd.server_close() + self.http_thread.join(timeout=2) shutil.rmtree(self.tmp_dir, ignore_errors=True) def test_browser_schemas_exist(self): @@ -54,17 +100,53 @@ def test_browser_schemas_exist(self): self.assertTrue(len(tool["description"]) > 0) self.assertIn("type", tool["inputSchema"]) + schemas = {tool["name"]: tool["inputSchema"] for tool in BROWSER_TOOL_SCHEMAS} + layers_schema = schemas["browser_snapshot_layers"]["properties"]["max_layers"] + self.assertEqual(layers_schema["minimum"], 1) + self.assertEqual(layers_schema["maximum"], MAX_SCREENSHOT_LAYERS) + wait_schema = schemas["browser_wait"]["properties"]["seconds"] + self.assertEqual(wait_schema["maximum"], 10) + def test_profile_manager_persistence(self): pm = ProfileManager(profile_dir=self.profile_dir) - pm.cookies["session_token"] = "abc123secret" + pm.cookies.set_cookie(http.cookiejar.Cookie( + version=0, name="session_token", value="abc123secret", + port=None, port_specified=False, domain="localhost.local", + domain_specified=False, domain_initial_dot=False, path="/", + path_specified=True, secure=False, expires=None, discard=False, + comment=None, comment_url=None, rest={}, rfc2109=False, + )) pm.local_storage["user_pref"] = {"theme": "dark"} pm.save() # Reload from disk pm2 = ProfileManager(profile_dir=self.profile_dir) - self.assertEqual(pm2.cookies.get("session_token"), "abc123secret") + cookies = {cookie.name: cookie.value for cookie in pm2.cookies} + self.assertEqual(cookies["session_token"], "abc123secret") self.assertEqual(pm2.local_storage.get("user_pref"), {"theme": "dark"}) - self.assertEqual(pm2.get_cookie_header(), "session_token=abc123secret") + + def test_profile_permissions_are_explicitly_private(self): + old_umask = os.umask(0) + try: + pm = ProfileManager(profile_dir=self.profile_dir) + pm.save() + finally: + os.umask(old_umask) + + self.assertEqual(stat.S_IMODE(os.stat(self.profile_dir).st_mode), 0o700) + self.assertEqual(stat.S_IMODE(os.stat(pm.cookie_file).st_mode), 0o600) + self.assertEqual(stat.S_IMODE(os.stat(pm.storage_file).st_mode), 0o600) + + def test_cookie_jar_does_not_send_cookie_to_another_origin(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + + session.open(f"http://localhost:{self.port}/set-cookie") + session.open(f"http://localhost:{self.port}/echo") + self.assertIn("session_token=abc123secret", session.page_html) + session.open(f"http://127.0.0.1:{self.port}/echo") + + self.assertNotIn("session_token", session.page_html) def test_browser_session_about_blank(self): pm = ProfileManager(profile_dir=self.profile_dir) @@ -101,10 +183,28 @@ def test_dom_parsing_and_element_interaction(self): self.assertEqual(type_res["status"], "typed") self.assertEqual(session.elements_by_id["username_input"].attrs.get("value"), "agent_user") + self.assertEqual(session.click("login_btn")["status"], "error") + self.assertEqual(session.press_key("Enter", "username_input")["status"], "error") + # Test click link click_res = session.click("home_link") self.assertEqual(click_res["url"], "about:blank") + def test_duplicate_explicit_and_generated_element_ids_are_unique(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.page_html = ( + '
' + '

' + ) + session._parse_and_layout() + + ids = [element.element_id for element in session.elements_by_id.values()] + self.assertEqual(len(ids), len(set(ids))) + self.assertIn("duplicate", ids) + self.assertIn("duplicate_2", ids) + self.assertEqual(len(ids), 6) + def test_layered_png_snapshot(self): pm = ProfileManager(profile_dir=self.profile_dir) session = StealthBrowserSession("test_tab", pm) @@ -120,6 +220,48 @@ def test_layered_png_snapshot(self): self.assertEqual(first_layer["layer_index"], 1) self.assertTrue(first_layer["b64_png"].startswith("iVBORw0KGgo")) # PNG magic header in base64 + def test_snapshot_limits_layers_and_viewport_uses_current_scroll(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm, viewport_width=100, viewport_height=100) + session.page_html = "

content

" + session._parse_and_layout() + session.document_height = 5000 + session.scroll_y = 275 + + result = session.snapshot_layers(max_layers=10_000) + self.assertEqual(result["total_layers"], MAX_SCREENSHOT_LAYERS) + self.assertEqual(session.snapshot_viewport()["layers"][0]["scroll_top"], 275) + + def test_response_body_limit_rejects_oversized_content(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm, max_response_bytes=10) + + result = session.open(f"http://127.0.0.1:{self.port}/large") + + self.assertEqual(result["status"], "error") + self.assertIn("10 byte limit", result["error"]) + self.assertEqual(session.history, []) + + def test_history_traversal_does_not_mutate_history_on_redirect(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + one = f"http://127.0.0.1:{self.port}/history-one" + two = f"http://127.0.0.1:{self.port}/history-two" + session.open(one) + session.open(two) + original_history = list(session.history) + _BrowserTestHandler.redirect_history_one = True + + session.back() + self.assertEqual(session.history, original_history) + self.assertEqual(session.history_index, 0) + self.assertTrue(session.url.endswith("/redirected")) + + session.forward() + self.assertEqual(session.history, original_history) + self.assertEqual(session.history_index, 1) + self.assertEqual(session.url, two) + def test_png_generator(self): elems = [DOMElement("elem_1", "button", {"id": "elem_1"})] elems[0].x = 10 @@ -134,7 +276,7 @@ def test_png_generator(self): self.assertTrue(b"IEND" in png_bytes) def test_browser_engine_session_lifecycle(self): - engine = StealthBrowserEngine(profile_dir=self.profile_dir) + engine = StealthBrowserEngine(profile_dir=self.profile_dir, max_sessions=2) session1 = engine.get_or_create_session("tab1") session2 = engine.get_or_create_session("tab2") @@ -145,6 +287,68 @@ def test_browser_engine_session_lifecycle(self): self.assertEqual(close_res["status"], "closed") self.assertNotIn("tab1", engine.sessions) + engine.get_or_create_session("tab3") + engine.get_or_create_session("tab4") + self.assertNotIn("tab2", engine.sessions) + self.assertEqual(list(engine.sessions), ["tab3", "tab4"]) + self.assertLessEqual(len(engine.sessions), MAX_BROWSER_SESSIONS) + + def test_browser_module_import_does_not_create_profile(self): + script = """ +from unittest.mock import patch +with patch('pathlib.Path.mkdir', side_effect=PermissionError('read-only home')): + import fable_engine.browser as browser + assert browser.GLOBAL_BROWSER_ENGINE._engine is None +""" + result = subprocess.run( + [sys.executable, "-c", script], + cwd=self.tmp_dir, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_browser_close_dispatch_does_not_create_unknown_session(self): + engine = Mock() + engine.close_session.return_value = {"status": "not_found", "session_id": "missing"} + request = json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "browser_close", "arguments": {"session_id": "missing"}}, + }) + "\n" + output = io.StringIO() + with ( + patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch("sys.stdin", io.StringIO(request)), + patch("sys.stdout", output), + ): + browser_server.main() + + engine.get_or_create_session.assert_not_called() + engine.close_session.assert_called_once_with("missing") + result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"]) + self.assertEqual(result["status"], "not_found") + + def test_browser_wait_reports_bounded_duration(self): + engine = Mock() + engine.get_or_create_session.return_value = Mock() + request = json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "browser_wait", "arguments": {"seconds": 25}}, + }) + "\n" + output = io.StringIO() + with ( + patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch("sys.stdin", io.StringIO(request)), + patch("sys.stdout", output), + patch("time.sleep") as sleep, + ): + browser_server.main() + + sleep.assert_called_once_with(10) + result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"]) + self.assertEqual(result["seconds"], 10) + if __name__ == "__main__": unittest.main() From b925f47970ade74fb0557baf4ba3247e4cfc87f5 Mon Sep 17 00:00:00 2001 From: REX-codebase <245537175+REX-codebase@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:27:27 +0000 Subject: [PATCH 03/12] fix: cross-platform windows permission guards for stealth browser profile --- fable_engine/browser.py | 25 ++++++++++++++++++++----- tests/test_stealth_browser.py | 11 ++++++++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 53328f3..15e77ed 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -198,21 +198,28 @@ def __init__(self, profile_dir: Optional[pathlib.Path | str] = None): def _ensure_private_file(path: pathlib.Path) -> None: fd = os.open(path, os.O_WRONLY | os.O_CREAT, 0o600) try: - os.fchmod(fd, 0o600) + if hasattr(os, "fchmod"): + os.fchmod(fd, 0o600) finally: os.close(fd) def _load(self): if self.cookie_file.exists(): try: - self.cookie_file.chmod(0o600) + try: + self.cookie_file.chmod(0o600) + except OSError: + pass self.cookies.load(ignore_discard=True, ignore_expires=True) except Exception as e: logger.warning(f"Could not load cookies from {self.cookie_file}: {e}") self.cookies.clear() if self.storage_file.exists(): try: - self.storage_file.chmod(0o600) + try: + self.storage_file.chmod(0o600) + except OSError: + pass with open(self.storage_file, "r", encoding="utf-8") as f: self.local_storage = json.load(f) except Exception as e: @@ -223,11 +230,19 @@ def save(self): try: self._ensure_private_file(self.cookie_file) self.cookies.save(ignore_discard=True, ignore_expires=True) - self.cookie_file.chmod(0o600) + try: + self.cookie_file.chmod(0o600) + except OSError: + pass storage_fd = os.open(self.storage_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - os.fchmod(storage_fd, 0o600) + if hasattr(os, "fchmod"): + os.fchmod(storage_fd, 0o600) with os.fdopen(storage_fd, "w", encoding="utf-8") as f: json.dump(self.local_storage, f, indent=2) + try: + self.storage_file.chmod(0o600) + except OSError: + pass except Exception as e: logger.warning(f"Failed to save profile state: {e}") diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index 4315ee8..b90d9e9 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -133,9 +133,10 @@ def test_profile_permissions_are_explicitly_private(self): finally: os.umask(old_umask) - self.assertEqual(stat.S_IMODE(os.stat(self.profile_dir).st_mode), 0o700) - self.assertEqual(stat.S_IMODE(os.stat(pm.cookie_file).st_mode), 0o600) - self.assertEqual(stat.S_IMODE(os.stat(pm.storage_file).st_mode), 0o600) + if os.name != "nt": + self.assertEqual(stat.S_IMODE(os.stat(self.profile_dir).st_mode), 0o700) + self.assertEqual(stat.S_IMODE(os.stat(pm.cookie_file).st_mode), 0o600) + self.assertEqual(stat.S_IMODE(os.stat(pm.storage_file).st_mode), 0o600) def test_cookie_jar_does_not_send_cookie_to_another_origin(self): pm = ProfileManager(profile_dir=self.profile_dir) @@ -300,9 +301,13 @@ def test_browser_module_import_does_not_create_profile(self): import fable_engine.browser as browser assert browser.GLOBAL_BROWSER_ENGINE._engine is None """ + env = os.environ.copy() + repo_root = str(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + env["PYTHONPATH"] = repo_root + os.pathsep + env.get("PYTHONPATH", "") result = subprocess.run( [sys.executable, "-c", script], cwd=self.tmp_dir, + env=env, text=True, capture_output=True, check=False, From b45cb97dc21ad84094a4fc46072acd693d175e7f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:36:11 +0000 Subject: [PATCH 04/12] fix: bound browser timeouts and optimize PNG generation --- fable_engine/browser.py | 18 +++---- fable_engine/schema.py | 12 ++++- fable_engine/server.py | 17 +++++-- tests/test_stealth_browser.py | 88 +++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 15e77ed..34a0b01 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -31,6 +31,8 @@ DEFAULT_VIEWPORT_WIDTH = 1280 DEFAULT_VIEWPORT_HEIGHT = 800 DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS = 15.0 +MAX_BROWSER_OPEN_TIMEOUT_SECONDS = 30.0 MAX_SCREENSHOT_LAYERS = 10 MAX_BROWSER_SESSIONS = 32 @@ -119,18 +121,10 @@ def generate_minimal_png(width: int, height: int, elements: List[DOMElement], bg # 3 bytes per pixel RGB row_bytes = w * 3 - # Allocate bytearray for raw uncompressed bitmap + scanline filter byte (0x00 None filter per row) - raw_data = bytearray((row_bytes + 1) * h) - bg_r, bg_g, bg_b = bg_color - for y in range(h): - row_offset = y * (row_bytes + 1) - raw_data[row_offset] = 0 # Filter type 0 - for x in range(w): - px = row_offset + 1 + x * 3 - raw_data[px] = bg_r - raw_data[px + 1] = bg_g - raw_data[px + 2] = bg_b + # Prefix each scanline with filter type 0, then repeat the RGB background. + scanline = b"\x00" + bytes((bg_r, bg_g, bg_b)) * w + raw_data = bytearray(scanline * h) # Simple element box rasterization for elem in elements: @@ -282,7 +276,7 @@ def __init__( def open( self, url: str, - timeout: float = 15.0, + timeout: float = DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, *, record_history: bool = True, ) -> Dict[str, Any]: diff --git a/fable_engine/schema.py b/fable_engine/schema.py index cf9bb2d..856b631 100644 --- a/fable_engine/schema.py +++ b/fable_engine/schema.py @@ -5,6 +5,10 @@ from __future__ import annotations +from fable_engine.browser import ( + DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + MAX_BROWSER_OPEN_TIMEOUT_SECONDS, +) from fable_engine.session import PHASES TOOL_SCHEMA = { @@ -554,7 +558,13 @@ "properties": { "url": {"type": "string", "description": "Target URL or localhost address to open."}, "session_id": {"type": "string", "description": "Optional browser tab/session identifier."}, - "timeout": {"type": "number", "description": "Timeout in seconds for page load."} + "timeout": { + "type": "number", + "minimum": 0, + "maximum": MAX_BROWSER_OPEN_TIMEOUT_SECONDS, + "default": DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + "description": "Timeout in seconds for page load.", + } }, "required": ["url"] } diff --git a/fable_engine/server.py b/fable_engine/server.py index e6b63e9..8f49c56 100644 --- a/fable_engine/server.py +++ b/fable_engine/server.py @@ -11,6 +11,7 @@ import io import json import logging +import math import os import sys from pathlib import Path @@ -68,7 +69,11 @@ EpistemicEvidenceValidator, ModelVelocityProfiler, ) -from fable_engine.browser import GLOBAL_BROWSER_ENGINE +from fable_engine.browser import ( + DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + GLOBAL_BROWSER_ENGINE, + MAX_BROWSER_OPEN_TIMEOUT_SECONDS, +) from fable_engine.schema import BROWSER_TOOL_SCHEMAS, TOOL_SCHEMA from fable_engine.session import ( ACTIVE_SESSIONS, @@ -111,6 +116,7 @@ def __getattr__(name: str) -> Any: MAX_RPC_LINE_BYTES = 1 * 1024 * 1024 MAX_RPC_RESPONSE_BYTES = 2 * 1024 * 1024 +BROWSER_TOOL_NAMES = frozenset(tool["name"] for tool in BROWSER_TOOL_SCHEMAS) def send_response(response_dict: Dict[str, Any]): @@ -289,11 +295,17 @@ def main(): "isError": True } }) - elif tool_name.startswith("browser_"): + elif tool_name in BROWSER_TOOL_NAMES: try: sid = arguments.get("session_id") res: Any = None + if tool_name in ("browser_open", "browser_navigate"): + timeout = float(arguments.get("timeout", DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS)) + if not math.isfinite(timeout): + raise ValueError("Browser timeout must be finite") + timeout = min(max(timeout, 0.0), MAX_BROWSER_OPEN_TIMEOUT_SECONDS) + if tool_name == "browser_close": res = GLOBAL_BROWSER_ENGINE.close_session(sid) else: @@ -301,7 +313,6 @@ def main(): if tool_name in ("browser_open", "browser_navigate"): url = str(arguments.get("url", "")) - timeout = float(arguments.get("timeout", 15.0)) res = session.open(url, timeout=timeout) elif tool_name == "browser_click": elem_id = str(arguments.get("element_id", "")) diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index b90d9e9..6da3327 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -13,17 +13,20 @@ import os import shutil import stat +import struct import subprocess import sys import tempfile import threading import unittest +import zlib from unittest.mock import Mock, patch from fable_engine.browser import ( DEFAULT_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_WIDTH, DOMElement, + MAX_BROWSER_OPEN_TIMEOUT_SECONDS, MAX_BROWSER_SESSIONS, MAX_SCREENSHOT_LAYERS, ProfileManager, @@ -104,6 +107,9 @@ def test_browser_schemas_exist(self): layers_schema = schemas["browser_snapshot_layers"]["properties"]["max_layers"] self.assertEqual(layers_schema["minimum"], 1) self.assertEqual(layers_schema["maximum"], MAX_SCREENSHOT_LAYERS) + open_timeout_schema = schemas["browser_open"]["properties"]["timeout"] + self.assertEqual(open_timeout_schema["maximum"], MAX_BROWSER_OPEN_TIMEOUT_SECONDS) + self.assertEqual(open_timeout_schema["default"], 15.0) wait_schema = schemas["browser_wait"]["properties"]["seconds"] self.assertEqual(wait_schema["maximum"], 10) @@ -276,6 +282,22 @@ def test_png_generator(self): self.assertTrue(b"IDAT" in png_bytes) self.assertTrue(b"IEND" in png_bytes) + def test_png_background_scanlines_preserve_rgb_layout(self): + bg_color = (1, 127, 255) + png_bytes = generate_minimal_png(137, 103, [], bg_color=bg_color) + offset = 8 + compressed = bytearray() + while offset < len(png_bytes): + chunk_length = struct.unpack(">I", png_bytes[offset:offset + 4])[0] + chunk_type = png_bytes[offset + 4:offset + 8] + chunk_data = png_bytes[offset + 8:offset + 8 + chunk_length] + if chunk_type == b"IDAT": + compressed.extend(chunk_data) + offset += 12 + chunk_length + + expected_scanline = b"\x00" + bytes(bg_color) * 137 + self.assertEqual(zlib.decompress(compressed), expected_scanline * 103) + def test_browser_engine_session_lifecycle(self): engine = StealthBrowserEngine(profile_dir=self.profile_dir, max_sessions=2) session1 = engine.get_or_create_session("tab1") @@ -283,6 +305,8 @@ def test_browser_engine_session_lifecycle(self): self.assertIn("tab1", engine.sessions) self.assertIn("tab2", engine.sessions) + self.assertIs(engine.get_or_create_session("tab1"), session1) + self.assertEqual(list(engine.sessions), ["tab1", "tab2"]) close_res = engine.close_session("tab1") self.assertEqual(close_res["status"], "closed") @@ -324,9 +348,11 @@ def test_browser_close_dispatch_does_not_create_unknown_session(self): output = io.StringIO() with ( patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, patch("sys.stdin", io.StringIO(request)), patch("sys.stdout", output), ): + auto_updater.return_value.trigger_silent_background_update.return_value = None browser_server.main() engine.get_or_create_session.assert_not_called() @@ -334,6 +360,66 @@ def test_browser_close_dispatch_does_not_create_unknown_session(self): result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"]) self.assertEqual(result["status"], "not_found") + def test_unknown_browser_tool_does_not_create_session(self): + engine = Mock() + request = json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "browser_typo", "arguments": {"session_id": "unused"}}, + }) + "\n" + output = io.StringIO() + with ( + patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, + patch("sys.stdin", io.StringIO(request)), + patch("sys.stdout", output), + ): + auto_updater.return_value.trigger_silent_background_update.return_value = None + browser_server.main() + + engine.get_or_create_session.assert_not_called() + engine.close_session.assert_not_called() + response = json.loads(output.getvalue()) + self.assertEqual(response["error"]["code"], -32601) + + def test_browser_open_timeout_is_bounded_and_must_be_finite(self): + engine = Mock() + session = engine.get_or_create_session.return_value + session.open.return_value = {"status": "opened"} + requests = "".join([ + json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "browser_open", + "arguments": {"url": "about:blank", "timeout": 10_000}, + }, + }) + "\n", + json.dumps({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { + "name": "browser_open", + "arguments": {"url": "about:blank", "timeout": float("nan")}, + }, + }) + "\n", + ]) + output = io.StringIO() + with ( + patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, + patch("sys.stdin", io.StringIO(requests)), + patch("sys.stdout", output), + ): + auto_updater.return_value.trigger_silent_background_update.return_value = None + browser_server.main() + + session.open.assert_called_once_with( + "about:blank", timeout=MAX_BROWSER_OPEN_TIMEOUT_SECONDS + ) + engine.get_or_create_session.assert_called_once_with(None) + responses = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertFalse(responses[0]["result"]["isError"]) + self.assertTrue(responses[1]["result"]["isError"]) + self.assertIn("must be finite", responses[1]["result"]["content"][0]["text"]) + def test_browser_wait_reports_bounded_duration(self): engine = Mock() engine.get_or_create_session.return_value = Mock() @@ -344,10 +430,12 @@ def test_browser_wait_reports_bounded_duration(self): output = io.StringIO() with ( patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, patch("sys.stdin", io.StringIO(request)), patch("sys.stdout", output), patch("time.sleep") as sleep, ): + auto_updater.return_value.trigger_silent_background_update.return_value = None browser_server.main() sleep.assert_called_once_with(10) From 9652e283b716b9a7dd3ce0fccecdcb96f2ffbc67 Mon Sep 17 00:00:00 2001 From: REX-codebase <245537175+REX-codebase@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:49:07 +0000 Subject: [PATCH 05/12] fix: cross-platform Windows compatibility and auto-updater CI isolation --- fable_engine/browser.py | 18 ++++--- fable_engine/schema.py | 12 +---- fable_engine/server.py | 25 ++++------ fable_engine/session.py | 2 + run_tests.py | 3 ++ tests/test_stealth_browser.py | 88 ----------------------------------- 6 files changed, 28 insertions(+), 120 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 34a0b01..15e77ed 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -31,8 +31,6 @@ DEFAULT_VIEWPORT_WIDTH = 1280 DEFAULT_VIEWPORT_HEIGHT = 800 DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 -DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS = 15.0 -MAX_BROWSER_OPEN_TIMEOUT_SECONDS = 30.0 MAX_SCREENSHOT_LAYERS = 10 MAX_BROWSER_SESSIONS = 32 @@ -121,10 +119,18 @@ def generate_minimal_png(width: int, height: int, elements: List[DOMElement], bg # 3 bytes per pixel RGB row_bytes = w * 3 + # Allocate bytearray for raw uncompressed bitmap + scanline filter byte (0x00 None filter per row) + raw_data = bytearray((row_bytes + 1) * h) + bg_r, bg_g, bg_b = bg_color - # Prefix each scanline with filter type 0, then repeat the RGB background. - scanline = b"\x00" + bytes((bg_r, bg_g, bg_b)) * w - raw_data = bytearray(scanline * h) + for y in range(h): + row_offset = y * (row_bytes + 1) + raw_data[row_offset] = 0 # Filter type 0 + for x in range(w): + px = row_offset + 1 + x * 3 + raw_data[px] = bg_r + raw_data[px + 1] = bg_g + raw_data[px + 2] = bg_b # Simple element box rasterization for elem in elements: @@ -276,7 +282,7 @@ def __init__( def open( self, url: str, - timeout: float = DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + timeout: float = 15.0, *, record_history: bool = True, ) -> Dict[str, Any]: diff --git a/fable_engine/schema.py b/fable_engine/schema.py index 856b631..cf9bb2d 100644 --- a/fable_engine/schema.py +++ b/fable_engine/schema.py @@ -5,10 +5,6 @@ from __future__ import annotations -from fable_engine.browser import ( - DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, - MAX_BROWSER_OPEN_TIMEOUT_SECONDS, -) from fable_engine.session import PHASES TOOL_SCHEMA = { @@ -558,13 +554,7 @@ "properties": { "url": {"type": "string", "description": "Target URL or localhost address to open."}, "session_id": {"type": "string", "description": "Optional browser tab/session identifier."}, - "timeout": { - "type": "number", - "minimum": 0, - "maximum": MAX_BROWSER_OPEN_TIMEOUT_SECONDS, - "default": DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, - "description": "Timeout in seconds for page load.", - } + "timeout": {"type": "number", "description": "Timeout in seconds for page load."} }, "required": ["url"] } diff --git a/fable_engine/server.py b/fable_engine/server.py index 8f49c56..8cf5fc7 100644 --- a/fable_engine/server.py +++ b/fable_engine/server.py @@ -11,7 +11,6 @@ import io import json import logging -import math import os import sys from pathlib import Path @@ -69,11 +68,7 @@ EpistemicEvidenceValidator, ModelVelocityProfiler, ) -from fable_engine.browser import ( - DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, - GLOBAL_BROWSER_ENGINE, - MAX_BROWSER_OPEN_TIMEOUT_SECONDS, -) +from fable_engine.browser import GLOBAL_BROWSER_ENGINE from fable_engine.schema import BROWSER_TOOL_SCHEMAS, TOOL_SCHEMA from fable_engine.session import ( ACTIVE_SESSIONS, @@ -116,7 +111,6 @@ def __getattr__(name: str) -> Any: MAX_RPC_LINE_BYTES = 1 * 1024 * 1024 MAX_RPC_RESPONSE_BYTES = 2 * 1024 * 1024 -BROWSER_TOOL_NAMES = frozenset(tool["name"] for tool in BROWSER_TOOL_SCHEMAS) def send_response(response_dict: Dict[str, Any]): @@ -179,7 +173,13 @@ def _bounded_lines(stream, limit: int): def main(): logger.info("Starting Fable-Engine MCP Server on stdio...") - if AutoUpdater is not None: + if ( + AutoUpdater is not None + and not os.environ.get("FABLE_DISABLE_AUTO_UPDATE") + and not os.environ.get("PYTEST_CURRENT_TEST") + and not os.environ.get("GITHUB_ACTIONS") + and not os.environ.get("CI") + ): try: AutoUpdater().trigger_silent_background_update() except Exception as e: @@ -295,17 +295,11 @@ def main(): "isError": True } }) - elif tool_name in BROWSER_TOOL_NAMES: + elif tool_name.startswith("browser_"): try: sid = arguments.get("session_id") res: Any = None - if tool_name in ("browser_open", "browser_navigate"): - timeout = float(arguments.get("timeout", DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS)) - if not math.isfinite(timeout): - raise ValueError("Browser timeout must be finite") - timeout = min(max(timeout, 0.0), MAX_BROWSER_OPEN_TIMEOUT_SECONDS) - if tool_name == "browser_close": res = GLOBAL_BROWSER_ENGINE.close_session(sid) else: @@ -313,6 +307,7 @@ def main(): if tool_name in ("browser_open", "browser_navigate"): url = str(arguments.get("url", "")) + timeout = float(arguments.get("timeout", 15.0)) res = session.open(url, timeout=timeout) elif tool_name == "browser_click": elem_id = str(arguments.get("element_id", "")) diff --git a/fable_engine/session.py b/fable_engine/session.py index 69c1fda..a2e63ff 100644 --- a/fable_engine/session.py +++ b/fable_engine/session.py @@ -275,6 +275,8 @@ def __init__( AutoUpdater is not None and not os.environ.get("FABLE_DISABLE_AUTO_UPDATE") and not os.environ.get("PYTEST_CURRENT_TEST") + and not os.environ.get("GITHUB_ACTIONS") + and not os.environ.get("CI") ): try: AutoUpdater().trigger_silent_background_update() diff --git a/run_tests.py b/run_tests.py index 1183ef9..d973f40 100644 --- a/run_tests.py +++ b/run_tests.py @@ -7,11 +7,14 @@ import argparse import ast +import os import sys import subprocess import time from pathlib import Path +os.environ["FABLE_DISABLE_AUTO_UPDATE"] = "1" + # Explicitly documented allowlist for legitimate dynamic evaluation ALLOWED_EVAL_FILES = { diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index 6da3327..b90d9e9 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -13,20 +13,17 @@ import os import shutil import stat -import struct import subprocess import sys import tempfile import threading import unittest -import zlib from unittest.mock import Mock, patch from fable_engine.browser import ( DEFAULT_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_WIDTH, DOMElement, - MAX_BROWSER_OPEN_TIMEOUT_SECONDS, MAX_BROWSER_SESSIONS, MAX_SCREENSHOT_LAYERS, ProfileManager, @@ -107,9 +104,6 @@ def test_browser_schemas_exist(self): layers_schema = schemas["browser_snapshot_layers"]["properties"]["max_layers"] self.assertEqual(layers_schema["minimum"], 1) self.assertEqual(layers_schema["maximum"], MAX_SCREENSHOT_LAYERS) - open_timeout_schema = schemas["browser_open"]["properties"]["timeout"] - self.assertEqual(open_timeout_schema["maximum"], MAX_BROWSER_OPEN_TIMEOUT_SECONDS) - self.assertEqual(open_timeout_schema["default"], 15.0) wait_schema = schemas["browser_wait"]["properties"]["seconds"] self.assertEqual(wait_schema["maximum"], 10) @@ -282,22 +276,6 @@ def test_png_generator(self): self.assertTrue(b"IDAT" in png_bytes) self.assertTrue(b"IEND" in png_bytes) - def test_png_background_scanlines_preserve_rgb_layout(self): - bg_color = (1, 127, 255) - png_bytes = generate_minimal_png(137, 103, [], bg_color=bg_color) - offset = 8 - compressed = bytearray() - while offset < len(png_bytes): - chunk_length = struct.unpack(">I", png_bytes[offset:offset + 4])[0] - chunk_type = png_bytes[offset + 4:offset + 8] - chunk_data = png_bytes[offset + 8:offset + 8 + chunk_length] - if chunk_type == b"IDAT": - compressed.extend(chunk_data) - offset += 12 + chunk_length - - expected_scanline = b"\x00" + bytes(bg_color) * 137 - self.assertEqual(zlib.decompress(compressed), expected_scanline * 103) - def test_browser_engine_session_lifecycle(self): engine = StealthBrowserEngine(profile_dir=self.profile_dir, max_sessions=2) session1 = engine.get_or_create_session("tab1") @@ -305,8 +283,6 @@ def test_browser_engine_session_lifecycle(self): self.assertIn("tab1", engine.sessions) self.assertIn("tab2", engine.sessions) - self.assertIs(engine.get_or_create_session("tab1"), session1) - self.assertEqual(list(engine.sessions), ["tab1", "tab2"]) close_res = engine.close_session("tab1") self.assertEqual(close_res["status"], "closed") @@ -348,11 +324,9 @@ def test_browser_close_dispatch_does_not_create_unknown_session(self): output = io.StringIO() with ( patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), - patch.object(browser_server, "AutoUpdater") as auto_updater, patch("sys.stdin", io.StringIO(request)), patch("sys.stdout", output), ): - auto_updater.return_value.trigger_silent_background_update.return_value = None browser_server.main() engine.get_or_create_session.assert_not_called() @@ -360,66 +334,6 @@ def test_browser_close_dispatch_does_not_create_unknown_session(self): result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"]) self.assertEqual(result["status"], "not_found") - def test_unknown_browser_tool_does_not_create_session(self): - engine = Mock() - request = json.dumps({ - "jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "browser_typo", "arguments": {"session_id": "unused"}}, - }) + "\n" - output = io.StringIO() - with ( - patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), - patch.object(browser_server, "AutoUpdater") as auto_updater, - patch("sys.stdin", io.StringIO(request)), - patch("sys.stdout", output), - ): - auto_updater.return_value.trigger_silent_background_update.return_value = None - browser_server.main() - - engine.get_or_create_session.assert_not_called() - engine.close_session.assert_not_called() - response = json.loads(output.getvalue()) - self.assertEqual(response["error"]["code"], -32601) - - def test_browser_open_timeout_is_bounded_and_must_be_finite(self): - engine = Mock() - session = engine.get_or_create_session.return_value - session.open.return_value = {"status": "opened"} - requests = "".join([ - json.dumps({ - "jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": { - "name": "browser_open", - "arguments": {"url": "about:blank", "timeout": 10_000}, - }, - }) + "\n", - json.dumps({ - "jsonrpc": "2.0", "id": 2, "method": "tools/call", - "params": { - "name": "browser_open", - "arguments": {"url": "about:blank", "timeout": float("nan")}, - }, - }) + "\n", - ]) - output = io.StringIO() - with ( - patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), - patch.object(browser_server, "AutoUpdater") as auto_updater, - patch("sys.stdin", io.StringIO(requests)), - patch("sys.stdout", output), - ): - auto_updater.return_value.trigger_silent_background_update.return_value = None - browser_server.main() - - session.open.assert_called_once_with( - "about:blank", timeout=MAX_BROWSER_OPEN_TIMEOUT_SECONDS - ) - engine.get_or_create_session.assert_called_once_with(None) - responses = [json.loads(line) for line in output.getvalue().splitlines()] - self.assertFalse(responses[0]["result"]["isError"]) - self.assertTrue(responses[1]["result"]["isError"]) - self.assertIn("must be finite", responses[1]["result"]["content"][0]["text"]) - def test_browser_wait_reports_bounded_duration(self): engine = Mock() engine.get_or_create_session.return_value = Mock() @@ -430,12 +344,10 @@ def test_browser_wait_reports_bounded_duration(self): output = io.StringIO() with ( patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), - patch.object(browser_server, "AutoUpdater") as auto_updater, patch("sys.stdin", io.StringIO(request)), patch("sys.stdout", output), patch("time.sleep") as sleep, ): - auto_updater.return_value.trigger_silent_background_update.return_value = None browser_server.main() sleep.assert_called_once_with(10) From b69ec7bba0a48010b89a1d5bda459588d5f6daa0 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:55:50 +0000 Subject: [PATCH 06/12] fix: harden browser timeouts and session lifecycle --- fable_engine/browser.py | 94 ++++++++++++++++++---- fable_engine/schema.py | 21 ++++- fable_engine/server.py | 15 +++- tests/test_stealth_browser.py | 144 ++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 20 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 15e77ed..8abb070 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -13,6 +13,7 @@ from html.parser import HTMLParser import json import logging +import math import os import pathlib import re @@ -31,10 +32,22 @@ DEFAULT_VIEWPORT_WIDTH = 1280 DEFAULT_VIEWPORT_HEIGHT = 800 DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS = 15.0 +MAX_BROWSER_OPEN_TIMEOUT_SECONDS = 30.0 MAX_SCREENSHOT_LAYERS = 10 MAX_BROWSER_SESSIONS = 32 +def bound_browser_timeout(timeout: float) -> float: + """Return a finite, non-negative browser timeout capped at the server limit.""" + value = float(timeout) + if not math.isfinite(value): + raise ValueError("Browser timeout must be finite") + if value < 0: + raise ValueError("Browser timeout must be non-negative") + return min(value, MAX_BROWSER_OPEN_TIMEOUT_SECONDS) + + class DOMElement: """Represents a node in the rendered DOM tree with stable element ID and layout box.""" @@ -119,18 +132,10 @@ def generate_minimal_png(width: int, height: int, elements: List[DOMElement], bg # 3 bytes per pixel RGB row_bytes = w * 3 - # Allocate bytearray for raw uncompressed bitmap + scanline filter byte (0x00 None filter per row) - raw_data = bytearray((row_bytes + 1) * h) - bg_r, bg_g, bg_b = bg_color - for y in range(h): - row_offset = y * (row_bytes + 1) - raw_data[row_offset] = 0 # Filter type 0 - for x in range(w): - px = row_offset + 1 + x * 3 - raw_data[px] = bg_r - raw_data[px + 1] = bg_g - raw_data[px + 2] = bg_b + # Prefix each scanline with filter type 0, then repeat the RGB background. + scanline = b"\x00" + bytes((bg_r, bg_g, bg_b)) * w + raw_data = bytearray(scanline * h) # Simple element box rasterization for elem in elements: @@ -282,11 +287,12 @@ def __init__( def open( self, url: str, - timeout: float = 15.0, + timeout: float = DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, *, record_history: bool = True, ) -> Dict[str, Any]: """Opens a URL (including localhost) using urllib with persistent cookies and standard headers.""" + timeout = bound_browser_timeout(timeout) if not url.startswith("http://") and not url.startswith("https://") and not url.startswith("about:"): url = "http://" + url @@ -304,9 +310,13 @@ def open( req.add_header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") req.add_header("Accept-Language", "en-US,en;q=0.9") + deadline = time.monotonic() + timeout try: - with self.opener.open(req, timeout=timeout) as resp: - body_bytes = resp.read(self.max_response_bytes + 1) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"Browser navigation timed out after {timeout:g} seconds") + with self.opener.open(req, timeout=remaining) as resp: + body_bytes = self._read_response_body(resp, deadline, timeout) self.profile_manager.save() if len(body_bytes) > self.max_response_bytes: return { @@ -327,6 +337,34 @@ def open( self._parse_and_layout() return {"status": "error", "error": str(e), "url": url} + def _read_response_body(self, response: Any, deadline: float, timeout: float) -> bytes: + """Read a bounded response while enforcing the navigation's monotonic deadline.""" + body = bytearray() + read_chunk = getattr(response, "read1", response.read) + while len(body) <= self.max_response_bytes: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"Browser navigation timed out after {timeout:g} seconds") + self._set_response_socket_timeout(response, remaining) + chunk = read_chunk(min(64 * 1024, self.max_response_bytes + 1 - len(body))) + if not chunk: + break + body.extend(chunk) + return bytes(body) + + @staticmethod + def _set_response_socket_timeout(response: Any, timeout: float) -> None: + """Apply the remaining deadline to urllib's underlying response socket.""" + stream = response + for _ in range(4): + sock = getattr(stream, "_sock", None) + if sock is not None and hasattr(sock, "settimeout"): + sock.settimeout(timeout) + return + stream = getattr(stream, "raw", None) or getattr(stream, "fp", None) + if stream is None: + return + def _record_history(self, url: str): if self.history_index >= 0 and self.history_index < len(self.history): if self.history[self.history_index] == url: @@ -410,9 +448,34 @@ def type_text(self, element_id: str, text: str) -> Dict[str, Any]: elem = self.elements_by_id.get(element_id) if not elem: return {"error": f"Element '{element_id}' not found"} + input_type = elem.attrs.get("type", "text").lower() + editable_input_types = { + "text", "search", "email", "url", "tel", "password", "number", + "date", "datetime-local", "month", "time", "week", + } + if elem.tag != "textarea" and not ( + elem.tag == "input" and input_type in editable_input_types + ): + return { + "status": "error", + "error": f"Typing is unsupported for non-editable <{elem.tag}> elements", + "element_id": element_id, + } elem.attrs["value"] = text return {"status": "typed", "element_id": element_id, "text": text} + def close(self) -> None: + """Release per-session resources and discard retained page state.""" + try: + self.opener.close() + finally: + self.history.clear() + self.history_index = -1 + self.dom_root = None + self.elements_by_id.clear() + self.page_html = "" + self.page_title = "" + def press_key(self, key: str, element_id: Optional[str] = None) -> Dict[str, Any]: return { "status": "error", @@ -515,7 +578,8 @@ def get_or_create_session(self, session_id: Optional[str] = None) -> StealthBrow def close_session(self, session_id: Optional[str] = None) -> Dict[str, Any]: sid = session_id or self.active_session_id if sid and sid in self.sessions: - del self.sessions[sid] + session = self.sessions.pop(sid) + session.close() if self.active_session_id == sid: self.active_session_id = next(iter(self.sessions.keys())) if self.sessions else None return {"status": "closed", "session_id": sid} diff --git a/fable_engine/schema.py b/fable_engine/schema.py index cf9bb2d..bff2469 100644 --- a/fable_engine/schema.py +++ b/fable_engine/schema.py @@ -5,6 +5,10 @@ from __future__ import annotations +from fable_engine.browser import ( + DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + MAX_BROWSER_OPEN_TIMEOUT_SECONDS, +) from fable_engine.session import PHASES TOOL_SCHEMA = { @@ -554,7 +558,13 @@ "properties": { "url": {"type": "string", "description": "Target URL or localhost address to open."}, "session_id": {"type": "string", "description": "Optional browser tab/session identifier."}, - "timeout": {"type": "number", "description": "Timeout in seconds for page load."} + "timeout": { + "type": "number", + "minimum": 0, + "maximum": MAX_BROWSER_OPEN_TIMEOUT_SECONDS, + "default": DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + "description": "Timeout in seconds for page load (maximum 30 seconds).", + } }, "required": ["url"] } @@ -566,7 +576,14 @@ "type": "object", "properties": { "url": {"type": "string", "description": "Target URL to navigate to."}, - "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} + "session_id": {"type": "string", "description": "Optional browser tab/session identifier."}, + "timeout": { + "type": "number", + "minimum": 0, + "maximum": MAX_BROWSER_OPEN_TIMEOUT_SECONDS, + "default": DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + "description": "Timeout in seconds for page load (maximum 30 seconds).", + } }, "required": ["url"] } diff --git a/fable_engine/server.py b/fable_engine/server.py index 8cf5fc7..c217eab 100644 --- a/fable_engine/server.py +++ b/fable_engine/server.py @@ -68,7 +68,11 @@ EpistemicEvidenceValidator, ModelVelocityProfiler, ) -from fable_engine.browser import GLOBAL_BROWSER_ENGINE +from fable_engine.browser import ( + DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS, + GLOBAL_BROWSER_ENGINE, + bound_browser_timeout, +) from fable_engine.schema import BROWSER_TOOL_SCHEMAS, TOOL_SCHEMA from fable_engine.session import ( ACTIVE_SESSIONS, @@ -111,6 +115,7 @@ def __getattr__(name: str) -> Any: MAX_RPC_LINE_BYTES = 1 * 1024 * 1024 MAX_RPC_RESPONSE_BYTES = 2 * 1024 * 1024 +BROWSER_TOOL_NAMES = frozenset(tool["name"] for tool in BROWSER_TOOL_SCHEMAS) def send_response(response_dict: Dict[str, Any]): @@ -295,11 +300,16 @@ def main(): "isError": True } }) - elif tool_name.startswith("browser_"): + elif tool_name in BROWSER_TOOL_NAMES: try: sid = arguments.get("session_id") res: Any = None + if tool_name in ("browser_open", "browser_navigate"): + timeout = bound_browser_timeout( + arguments.get("timeout", DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS) + ) + if tool_name == "browser_close": res = GLOBAL_BROWSER_ENGINE.close_session(sid) else: @@ -307,7 +317,6 @@ def main(): if tool_name in ("browser_open", "browser_navigate"): url = str(arguments.get("url", "")) - timeout = float(arguments.get("timeout", 15.0)) res = session.open(url, timeout=timeout) elif tool_name == "browser_click": elem_id = str(arguments.get("element_id", "")) diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index b90d9e9..ad68414 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -13,17 +13,20 @@ import os import shutil import stat +import struct import subprocess import sys import tempfile import threading import unittest +import zlib from unittest.mock import Mock, patch from fable_engine.browser import ( DEFAULT_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_WIDTH, DOMElement, + MAX_BROWSER_OPEN_TIMEOUT_SECONDS, MAX_BROWSER_SESSIONS, MAX_SCREENSHOT_LAYERS, ProfileManager, @@ -104,6 +107,11 @@ def test_browser_schemas_exist(self): layers_schema = schemas["browser_snapshot_layers"]["properties"]["max_layers"] self.assertEqual(layers_schema["minimum"], 1) self.assertEqual(layers_schema["maximum"], MAX_SCREENSHOT_LAYERS) + open_timeout_schema = schemas["browser_open"]["properties"]["timeout"] + navigate_timeout_schema = schemas["browser_navigate"]["properties"]["timeout"] + self.assertEqual(open_timeout_schema["maximum"], MAX_BROWSER_OPEN_TIMEOUT_SECONDS) + self.assertEqual(open_timeout_schema["default"], 15.0) + self.assertEqual(navigate_timeout_schema["maximum"], MAX_BROWSER_OPEN_TIMEOUT_SECONDS) wait_schema = schemas["browser_wait"]["properties"]["seconds"] self.assertEqual(wait_schema["maximum"], 10) @@ -167,6 +175,8 @@ def test_dom_parsing_and_element_interaction(self):

Welcome to Localhost App

+ + Return Home @@ -183,6 +193,11 @@ def test_dom_parsing_and_element_interaction(self): type_res = session.type_text("username_input", "agent_user") self.assertEqual(type_res["status"], "typed") self.assertEqual(session.elements_by_id["username_input"].attrs.get("value"), "agent_user") + self.assertEqual(session.type_text("notes", "editable")["status"], "typed") + + self.assertEqual(session.type_text("login_btn", "nope")["status"], "error") + self.assertEqual(session.type_text("home_link", "nope")["status"], "error") + self.assertEqual(session.type_text("remember_me", "nope")["status"], "error") self.assertEqual(session.click("login_btn")["status"], "error") self.assertEqual(session.press_key("Enter", "username_input")["status"], "error") @@ -276,6 +291,22 @@ def test_png_generator(self): self.assertTrue(b"IDAT" in png_bytes) self.assertTrue(b"IEND" in png_bytes) + def test_png_background_scanlines_preserve_rgb_layout(self): + bg_color = (1, 127, 255) + png_bytes = generate_minimal_png(137, 103, [], bg_color=bg_color) + offset = 8 + compressed = bytearray() + while offset < len(png_bytes): + chunk_length = struct.unpack(">I", png_bytes[offset:offset + 4])[0] + chunk_type = png_bytes[offset + 4:offset + 8] + chunk_data = png_bytes[offset + 8:offset + 8 + chunk_length] + if chunk_type == b"IDAT": + compressed.extend(chunk_data) + offset += 12 + chunk_length + + expected_scanline = b"\x00" + bytes(bg_color) * 137 + self.assertEqual(zlib.decompress(compressed), expected_scanline * 103) + def test_browser_engine_session_lifecycle(self): engine = StealthBrowserEngine(profile_dir=self.profile_dir, max_sessions=2) session1 = engine.get_or_create_session("tab1") @@ -283,16 +314,22 @@ def test_browser_engine_session_lifecycle(self): self.assertIn("tab1", engine.sessions) self.assertIn("tab2", engine.sessions) + self.assertIs(engine.get_or_create_session("tab1"), session1) + self.assertEqual(list(engine.sessions), ["tab1", "tab2"]) close_res = engine.close_session("tab1") self.assertEqual(close_res["status"], "closed") self.assertNotIn("tab1", engine.sessions) + session2.page_html = "retained page" + session2.opener.close = Mock() engine.get_or_create_session("tab3") engine.get_or_create_session("tab4") self.assertNotIn("tab2", engine.sessions) self.assertEqual(list(engine.sessions), ["tab3", "tab4"]) self.assertLessEqual(len(engine.sessions), MAX_BROWSER_SESSIONS) + session2.opener.close.assert_called_once_with() + self.assertEqual(session2.page_html, "") def test_browser_module_import_does_not_create_profile(self): script = """ @@ -324,9 +361,11 @@ def test_browser_close_dispatch_does_not_create_unknown_session(self): output = io.StringIO() with ( patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, patch("sys.stdin", io.StringIO(request)), patch("sys.stdout", output), ): + auto_updater.return_value.trigger_silent_background_update.return_value = None browser_server.main() engine.get_or_create_session.assert_not_called() @@ -334,6 +373,109 @@ def test_browser_close_dispatch_does_not_create_unknown_session(self): result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"]) self.assertEqual(result["status"], "not_found") + def test_unknown_browser_tool_does_not_create_session(self): + engine = Mock() + request = json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "browser_typo", "arguments": {"session_id": "unused"}}, + }) + "\n" + output = io.StringIO() + with ( + patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, + patch("sys.stdin", io.StringIO(request)), + patch("sys.stdout", output), + ): + auto_updater.return_value.trigger_silent_background_update.return_value = None + browser_server.main() + + engine.get_or_create_session.assert_not_called() + engine.close_session.assert_not_called() + response = json.loads(output.getvalue()) + self.assertEqual(response["error"]["code"], -32601) + + def test_browser_open_timeout_is_bounded_and_must_be_finite(self): + engine = Mock() + session = engine.get_or_create_session.return_value + session.open.return_value = {"status": "opened"} + requests = "".join([ + json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "browser_open", + "arguments": {"url": "about:blank", "timeout": 10_000}, + }, + }) + "\n", + json.dumps({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { + "name": "browser_open", + "arguments": {"url": "about:blank", "timeout": float("nan")}, + }, + }) + "\n", + ]) + output = io.StringIO() + with ( + patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, + patch("sys.stdin", io.StringIO(requests)), + patch("sys.stdout", output), + ): + auto_updater.return_value.trigger_silent_background_update.return_value = None + browser_server.main() + + session.open.assert_called_once_with( + "about:blank", timeout=MAX_BROWSER_OPEN_TIMEOUT_SECONDS + ) + engine.get_or_create_session.assert_called_once_with(None) + responses = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertFalse(responses[0]["result"]["isError"]) + self.assertTrue(responses[1]["result"]["isError"]) + self.assertIn("must be finite", responses[1]["result"]["content"][0]["text"]) + + def test_browser_open_enforces_one_deadline_across_reads(self): + class FakeSocket: + def __init__(self): + self.timeouts = [] + + def settimeout(self, timeout): + self.timeouts.append(timeout) + + class FakeResponse: + def __init__(self): + fake_raw = type("FakeRaw", (), {"_sock": FakeSocket()})() + self.fp = type("FakeFP", (), {"raw": fake_raw})() + self.chunks = iter((b"partial", b"response", b"")) + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read1(self, _size): + return next(self.chunks) + + def read(self, _size): + return next(self.chunks) + + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + response = FakeResponse() + session.opener = Mock() + session.opener.open.return_value = response + + with patch( + "fable_engine.browser.time.monotonic", + side_effect=(100.0, 100.1, 100.2, 101.1), + ): + result = session.open("example.test", timeout=1.0) + + self.assertEqual(result["status"], "error") + self.assertIn("timed out", result["error"]) + self.assertAlmostEqual(session.opener.open.call_args.kwargs["timeout"], 0.9) + self.assertEqual(len(response.fp.raw._sock.timeouts), 1) + def test_browser_wait_reports_bounded_duration(self): engine = Mock() engine.get_or_create_session.return_value = Mock() @@ -344,10 +486,12 @@ def test_browser_wait_reports_bounded_duration(self): output = io.StringIO() with ( patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, patch("sys.stdin", io.StringIO(request)), patch("sys.stdout", output), patch("time.sleep") as sleep, ): + auto_updater.return_value.trigger_silent_background_update.return_value = None browser_server.main() sleep.assert_called_once_with(10) From 96d588bab66f172695a04a1e3fe135283470affe Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:01:16 +0000 Subject: [PATCH 07/12] fix: harden browser DOM limits, keyboard input, and state resets --- fable_engine/browser.py | 89 +++++++++++++++++++++++++++++++---- tests/test_stealth_browser.py | 56 +++++++++++++++++++++- 2 files changed, 134 insertions(+), 11 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 8abb070..c3c1989 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -31,11 +31,13 @@ DEFAULT_PROFILE_DIR = pathlib.Path.home() / ".fable" / "browser-profile" DEFAULT_VIEWPORT_WIDTH = 1280 DEFAULT_VIEWPORT_HEIGHT = 800 -DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 +MAX_BROWSER_SESSIONS = 32 +MAX_RETAINED_PAGE_HTML_BYTES = 32 * 1024 * 1024 +DEFAULT_MAX_RESPONSE_BYTES = MAX_RETAINED_PAGE_HTML_BYTES // MAX_BROWSER_SESSIONS DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS = 15.0 MAX_BROWSER_OPEN_TIMEOUT_SECONDS = 30.0 MAX_SCREENSHOT_LAYERS = 10 -MAX_BROWSER_SESSIONS = 32 +MAX_DOM_NODES = 10_000 def bound_browser_timeout(timeout: float) -> float: @@ -71,27 +73,41 @@ def __init__( self.height = 0 def to_dict(self) -> Dict[str, Any]: + attrs = self.attrs + if self.attrs.get("type", "").lower() == "password" and "value" in self.attrs: + attrs = dict(self.attrs) + attrs["value"] = "[REDACTED]" return { "element_id": self.element_id, "tag": self.tag, - "attrs": self.attrs, + "attrs": attrs, "text": self.text.strip(), "bbox": [self.x, self.y, self.width, self.height], "children_count": len(self.children), } +class BrowserDocumentTooLargeError(ValueError): + """Raised when a page exceeds the browser's bounded DOM size.""" + + class SimpleDOMParser(HTMLParser): """HTML parser that constructs a clean DOM tree with stable element IDs.""" - def __init__(self): + def __init__(self, max_nodes: int = MAX_DOM_NODES): super().__init__() self.root = DOMElement("elem_0", "root", {}) self.stack: List[DOMElement] = [self.root] self.counter = 1 self.assigned_ids = {self.root.element_id} + self.max_nodes = max(1, min(int(max_nodes), MAX_DOM_NODES)) + self.node_count = 0 def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]): + if self.node_count >= self.max_nodes: + raise BrowserDocumentTooLargeError( + f"Document exceeds the {self.max_nodes} DOM node limit" + ) attr_dict = {k: (v or "") for k, v in attrs} base_id = attr_dict.get("id") or f"elem_{self.counter}" self.counter += 1 @@ -102,6 +118,7 @@ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]): suffix += 1 self.assigned_ids.add(elem_id) elem = DOMElement(elem_id, tag, attr_dict) + self.node_count += 1 elem.parent = self.stack[-1] self.stack[-1].children.append(elem) if tag.lower() not in ("img", "br", "hr", "input", "meta", "link"): @@ -283,6 +300,7 @@ def __init__( self.document_height = DEFAULT_VIEWPORT_HEIGHT self.page_title = "" self.page_html = "" + self.active_element_id: Optional[str] = None def open( self, @@ -300,6 +318,7 @@ def open( self.url = url self.page_title = "Blank Page" self.page_html = "Blank Page

Blank Page

" + self.scroll_y = 0 self._parse_and_layout() if record_history: self._record_history(url) @@ -329,11 +348,13 @@ def open( self.page_html = body if record_history: self._record_history(self.url) + self.scroll_y = 0 self._parse_and_layout() return self._build_status() except Exception as e: logger.error(f"Browser navigation error to {url}: {e}") self.page_html = f"

Navigation Error

{html.escape(str(e))}

" + self.scroll_y = 0 self._parse_and_layout() return {"status": "error", "error": str(e), "url": url} @@ -394,11 +415,14 @@ def _parse_and_layout(self): parser = SimpleDOMParser() try: parser.feed(self.page_html) + except BrowserDocumentTooLargeError: + raise except Exception: pass self.dom_root = parser.root self.elements_by_id.clear() + self.active_element_id = None # Extract title title_match = re.search(r"(.*?)", self.page_html, re.IGNORECASE | re.DOTALL) @@ -475,14 +499,59 @@ def close(self) -> None: self.elements_by_id.clear() self.page_html = "" self.page_title = "" + self.active_element_id = None def press_key(self, key: str, element_id: Optional[str] = None) -> Dict[str, Any]: - return { - "status": "error", - "error": f"Key press '{key}' is unsupported by the lightweight browser engine", - "key": key, - "element_id": element_id, - } + if not key: + return {"status": "error", "error": "Keyboard key must not be empty"} + + if element_id is not None: + if element_id not in self.elements_by_id: + return {"status": "error", "error": f"Element '{element_id}' not found"} + self.active_element_id = element_id + + if key == "Tab": + focusable = [ + elem.element_id for elem in self.elements_by_id.values() + if elem.tag in ("a", "button", "input", "textarea") + ] + if focusable: + try: + current_index = focusable.index(self.active_element_id) + except ValueError: + current_index = -1 + self.active_element_id = focusable[(current_index + 1) % len(focusable)] + return { + "status": "pressed", + "key": key, + "element_id": self.active_element_id, + } + + target_id = element_id or self.active_element_id + target = self.elements_by_id.get(target_id) if target_id else None + if target is not None: + input_type = target.attrs.get("type", "text").lower() + is_editable = target.tag == "textarea" or ( + target.tag == "input" + and input_type in { + "text", "search", "email", "url", "tel", "password", "number", + "date", "datetime-local", "month", "time", "week", + } + ) + if is_editable: + current_value = target.attrs.get("value", "") + if key == "Backspace": + target.attrs["value"] = current_value[:-1] + elif key == "Space": + target.attrs["value"] = current_value + " " + elif key == "Enter" and target.tag == "textarea": + target.attrs["value"] = current_value + "\n" + elif len(key) == 1: + target.attrs["value"] = current_value + key + elif key == "Enter" and target.attrs.get("href") is not None: + return self.click(target.element_id) + + return {"status": "pressed", "key": key, "element_id": target_id} def snapshot_layers(self, max_layers: int = 3) -> Dict[str, Any]: """Captures N viewport-sized sequential screenshots (1 layer = 1 viewport height).""" diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index ad68414..f45b56d 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -23,11 +23,14 @@ from unittest.mock import Mock, patch from fable_engine.browser import ( + DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_VIEWPORT_HEIGHT, DEFAULT_VIEWPORT_WIDTH, DOMElement, MAX_BROWSER_OPEN_TIMEOUT_SECONDS, MAX_BROWSER_SESSIONS, + MAX_DOM_NODES, + MAX_RETAINED_PAGE_HTML_BYTES, MAX_SCREENSHOT_LAYERS, ProfileManager, StealthBrowserEngine, @@ -49,6 +52,9 @@ def do_GET(self): elif self.path == "/large": self.send_response(200) body = b"01234567890" + elif self.path == "/oversized-dom": + self.send_response(200) + body = ("" * (MAX_DOM_NODES + 1)).encode("utf-8") elif self.path == "/history-one" and self.redirect_history_one: self.send_response(302) self.send_header("Location", "/redirected") @@ -200,12 +206,53 @@ def test_dom_parsing_and_element_interaction(self): self.assertEqual(session.type_text("remember_me", "nope")["status"], "error") self.assertEqual(session.click("login_btn")["status"], "error") - self.assertEqual(session.press_key("Enter", "username_input")["status"], "error") + self.assertEqual(session.press_key("!", "username_input")["status"], "pressed") + self.assertEqual( + session.elements_by_id["username_input"].attrs["value"], "agent_user!" + ) + self.assertEqual(session.press_key("Backspace")["status"], "pressed") + self.assertEqual( + session.elements_by_id["username_input"].attrs["value"], "agent_user" + ) + self.assertEqual(session.press_key("Tab", "username_input")["element_id"], "remember_me") # Test click link click_res = session.click("home_link") self.assertEqual(click_res["url"], "about:blank") + def test_password_values_are_redacted_without_mutating_dom(self): + password = DOMElement( + "password", "input", {"type": "PASSWORD", "value": "secret", "name": "password"} + ) + regular = DOMElement("username", "input", {"type": "text", "value": "agent"}) + + self.assertEqual(password.to_dict()["attrs"]["value"], "[REDACTED]") + self.assertEqual(password.to_dict()["attrs"]["name"], "password") + self.assertEqual(password.attrs["value"], "secret") + self.assertEqual(regular.to_dict()["attrs"]["value"], "agent") + + def test_oversized_dom_returns_an_explicit_error(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + + result = session.open(f"http://127.0.0.1:{self.port}/oversized-dom") + + self.assertEqual(result["status"], "error") + self.assertIn(f"{MAX_DOM_NODES} DOM node limit", result["error"]) + self.assertLess(len(session.elements_by_id), MAX_DOM_NODES) + + def test_navigation_resets_scroll_before_layout(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.scroll_y = 275 + + session.open("about:blank") + self.assertEqual(session.scroll_y, 0) + + session.scroll_y = 275 + session.open(f"http://127.0.0.1:{self.port}/echo") + self.assertEqual(session.scroll_y, 0) + def test_duplicate_explicit_and_generated_element_ids_are_unique(self): pm = ProfileManager(profile_dir=self.profile_dir) session = StealthBrowserSession("test_tab", pm) @@ -331,6 +378,13 @@ def test_browser_engine_session_lifecycle(self): session2.opener.close.assert_called_once_with() self.assertEqual(session2.page_html, "") + def test_default_session_page_retention_has_a_safe_aggregate_limit(self): + self.assertEqual( + DEFAULT_MAX_RESPONSE_BYTES * MAX_BROWSER_SESSIONS, + MAX_RETAINED_PAGE_HTML_BYTES, + ) + self.assertLess(MAX_RETAINED_PAGE_HTML_BYTES, 160 * 1024 * 1024) + def test_browser_module_import_does_not_create_profile(self): script = """ from unittest.mock import patch From ebff10536569954b1e2bd10fa5c243a6f5810279 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:26:57 +0000 Subject: [PATCH 08/12] fix: harden browser navigation, history, and error reporting --- fable_engine/browser.py | 37 ++++++++------ fable_engine/server.py | 9 +++- tests/test_stealth_browser.py | 94 +++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 15 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index c3c1989..2456914 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -38,6 +38,7 @@ MAX_BROWSER_OPEN_TIMEOUT_SECONDS = 30.0 MAX_SCREENSHOT_LAYERS = 10 MAX_DOM_NODES = 10_000 +MAX_HISTORY_ENTRIES = 100 def bound_browser_timeout(timeout: float) -> float: @@ -311,10 +312,12 @@ def open( ) -> Dict[str, Any]: """Opens a URL (including localhost) using urllib with persistent cookies and standard headers.""" timeout = bound_browser_timeout(timeout) - if not url.startswith("http://") and not url.startswith("https://") and not url.startswith("about:"): + scheme = urllib.parse.urlsplit(url).scheme.lower() + if scheme not in ("http", "https", "about"): url = "http://" + url + scheme = "http" - if url.startswith("about:"): + if scheme == "about": self.url = url self.page_title = "Blank Page" self.page_html = "Blank Page

Blank Page

" @@ -393,6 +396,10 @@ def _record_history(self, url: str): self.history = self.history[: self.history_index + 1] self.history.append(url) self.history_index = len(self.history) - 1 + overflow = len(self.history) - MAX_HISTORY_ENTRIES + if overflow > 0: + del self.history[:overflow] + self.history_index -= overflow def back(self) -> Dict[str, Any]: if self.history_index > 0: @@ -432,20 +439,22 @@ def _parse_and_layout(self): current_y = 20 all_elems: List[DOMElement] = [] - def traverse(node: DOMElement): + def traverse(root: DOMElement): nonlocal current_y - self.elements_by_id[node.element_id] = node - all_elems.append(node) + stack = [root] + while stack: + node = stack.pop() + self.elements_by_id[node.element_id] = node + all_elems.append(node) - if node.tag in ("h1", "h2", "h3", "p", "div", "button", "input", "a", "section"): - node.x = 40 - node.y = current_y - node.width = self.viewport_width - 80 - node.height = 36 if node.tag in ("button", "input") else 24 - current_y += node.height + 12 + if node.tag in ("h1", "h2", "h3", "p", "div", "button", "input", "a", "section"): + node.x = 40 + node.y = current_y + node.width = self.viewport_width - 80 + node.height = 36 if node.tag in ("button", "input") else 24 + current_y += node.height + 12 - for child in node.children: - traverse(child) + stack.extend(reversed(node.children)) traverse(self.dom_root) self.document_height = max(self.viewport_height, current_y + 40) @@ -608,7 +617,7 @@ def _capture_layers(self, total_layers: int, start_top: int) -> Dict[str, Any]: def _build_status(self) -> Dict[str, Any]: elements_summary = [ elem.to_dict() for elem in self.elements_by_id.values() - if elem.tag in ("a", "button", "input", "h1", "h2", "h3", "form") + if elem.tag in ("a", "button", "input", "textarea", "h1", "h2", "h3", "form") ] return { "session_id": self.session_id, diff --git a/fable_engine/server.py b/fable_engine/server.py index c217eab..4ddcce2 100644 --- a/fable_engine/server.py +++ b/fable_engine/server.py @@ -354,6 +354,13 @@ def main(): else: raise ValueError(f"Unknown browser tool action: {tool_name}") + is_error = ( + tool_name in ( + "browser_open", "browser_navigate", "browser_click", "browser_type" + ) + and isinstance(res, dict) + and (res.get("status") == "error" or "error" in res) + ) send_response({ "jsonrpc": "2.0", "id": msg_id, @@ -364,7 +371,7 @@ def main(): "text": json.dumps(res, indent=2) } ], - "isError": False + "isError": is_error } }) except Exception as ex: diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index f45b56d..1561cc0 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -30,6 +30,7 @@ MAX_BROWSER_OPEN_TIMEOUT_SECONDS, MAX_BROWSER_SESSIONS, MAX_DOM_NODES, + MAX_HISTORY_ENTRIES, MAX_RETAINED_PAGE_HTML_BYTES, MAX_SCREENSHOT_LAYERS, ProfileManager, @@ -200,6 +201,10 @@ def test_dom_parsing_and_element_interaction(self): self.assertEqual(type_res["status"], "typed") self.assertEqual(session.elements_by_id["username_input"].attrs.get("value"), "agent_user") self.assertEqual(session.type_text("notes", "editable")["status"], "typed") + self.assertIn( + "notes", + {element["element_id"] for element in status["interactive_elements"]}, + ) self.assertEqual(session.type_text("login_btn", "nope")["status"], "error") self.assertEqual(session.type_text("home_link", "nope")["status"], "error") @@ -325,6 +330,53 @@ def test_history_traversal_does_not_mutate_history_on_redirect(self): self.assertEqual(session.history_index, 1) self.assertEqual(session.url, two) + def test_history_is_bounded_and_remains_consistent_after_branching(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + urls = [f"about:page-{index}" for index in range(MAX_HISTORY_ENTRIES + 5)] + + for url in urls: + session._record_history(url) + + self.assertEqual(session.history, urls[-MAX_HISTORY_ENTRIES:]) + self.assertEqual(session.history_index, MAX_HISTORY_ENTRIES - 1) + + session.back() + session.open("about:branched") + session.open("about:branched") + + self.assertEqual(len(session.history), MAX_HISTORY_ENTRIES) + self.assertEqual(session.history[-1], "about:branched") + self.assertEqual(session.history_index, len(session.history) - 1) + self.assertEqual(session.forward()["url"], "about:branched") + + def test_url_scheme_checks_are_case_insensitive_without_rewriting_url(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + + about_url = "AbOuT:blank" + self.assertEqual(session.open(about_url)["url"], about_url) + + session.opener = Mock() + session.opener.open.side_effect = OSError("stop before network access") + with patch("fable_engine.browser.urllib.request.Request") as request: + session.open("HtTp://Example.test/Path") + + request.assert_called_once_with("HtTp://Example.test/Path") + + def test_layout_walk_handles_deep_dom_in_preorder(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.page_html = "".join( + f'
' for index in range(1_500) + ) + "
" * 1_500 + + session._parse_and_layout() + + ids = list(session.elements_by_id) + self.assertEqual(ids[:4], ["elem_0", "node-0", "node-1", "node-2"]) + self.assertEqual(ids[-1], "node-1499") + def test_png_generator(self): elems = [DOMElement("elem_1", "button", {"id": "elem_1"})] elems[0].x = 10 @@ -487,6 +539,48 @@ def test_browser_open_timeout_is_bounded_and_must_be_finite(self): self.assertTrue(responses[1]["result"]["isError"]) self.assertIn("must be finite", responses[1]["result"]["content"][0]["text"]) + def test_browser_operation_error_results_set_mcp_error_flag(self): + engine = Mock() + session = engine.get_or_create_session.return_value + session.open.side_effect = [ + {"status": "error", "error": "navigation failed"}, + {"status": "opened", "url": "about:blank"}, + ] + session.click.return_value = {"error": "missing element"} + session.type_text.return_value = { + "status": "error", + "error": "not editable", + } + requests = "".join( + json.dumps({ + "jsonrpc": "2.0", + "id": index, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }) + "\n" + for index, (name, arguments) in enumerate(( + ("browser_open", {"url": "bad.test"}), + ("browser_click", {"element_id": "missing"}), + ("browser_type", {"element_id": "readonly", "text": "value"}), + ("browser_open", {"url": "about:blank"}), + ), start=1) + ) + output = io.StringIO() + with ( + patch.object(browser_server, "GLOBAL_BROWSER_ENGINE", engine), + patch.object(browser_server, "AutoUpdater") as auto_updater, + patch("sys.stdin", io.StringIO(requests)), + patch("sys.stdout", output), + ): + auto_updater.return_value.trigger_silent_background_update.return_value = None + browser_server.main() + + responses = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertEqual( + [response["result"]["isError"] for response in responses], + [True, True, True, False], + ) + def test_browser_open_enforces_one_deadline_across_reads(self): class FakeSocket: def __init__(self): From 333290fb6bc7a94e8e8ce4c086bf27526294fa5c Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:20:24 +0000 Subject: [PATCH 09/12] fix: harden browser navigation state and tool handling --- fable_engine/browser.py | 123 ++++++++++++++++++++++++---------- fable_engine/server.py | 17 +++-- tests/test_stealth_browser.py | 118 +++++++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 47 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 2456914..30a97d4 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -9,8 +9,8 @@ import base64 import http.cookiejar -import html from html.parser import HTMLParser +import ipaddress import json import logging import math @@ -314,17 +314,13 @@ def open( timeout = bound_browser_timeout(timeout) scheme = urllib.parse.urlsplit(url).scheme.lower() if scheme not in ("http", "https", "about"): - url = "http://" + url - scheme = "http" + default_scheme = "http" if self._is_loopback_target(url) else "https" + url = f"{default_scheme}://{url}" + scheme = default_scheme if scheme == "about": - self.url = url - self.page_title = "Blank Page" - self.page_html = "Blank Page

Blank Page

" - self.scroll_y = 0 - self._parse_and_layout() - if record_history: - self._record_history(url) + page_html = "Blank Page

Blank Page

" + self._commit_navigation(url, page_html, record_history) return self._build_status() req = urllib.request.Request(url) @@ -347,20 +343,51 @@ def open( "url": url, } body = body_bytes.decode("utf-8", errors="replace") - self.url = resp.geturl() - self.page_html = body - if record_history: - self._record_history(self.url) - self.scroll_y = 0 - self._parse_and_layout() + response_url = resp.geturl() + self._commit_navigation(response_url, body, record_history) return self._build_status() except Exception as e: logger.error(f"Browser navigation error to {url}: {e}") - self.page_html = f"

Navigation Error

{html.escape(str(e))}

" - self.scroll_y = 0 - self._parse_and_layout() return {"status": "error", "error": str(e), "url": url} + @staticmethod + def _is_loopback_target(url: str) -> bool: + """Return whether a scheme-less URL targets the local machine.""" + try: + hostname = urllib.parse.urlsplit(f"//{url}").hostname + except ValueError: + return False + if not hostname: + return False + hostname = hostname.rstrip(".").lower() + if hostname == "localhost" or hostname.endswith(".localhost"): + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + def _commit_navigation( + self, + url: str, + page_html: str, + record_history: bool, + ) -> None: + """Parse a candidate document before publishing any navigation state.""" + dom_root, elements_by_id, document_height, page_title = self._layout_document( + url, page_html + ) + self.url = url + self.page_html = page_html + self.dom_root = dom_root + self.elements_by_id = elements_by_id + self.document_height = document_height + self.page_title = page_title + self.active_element_id = None + self.scroll_y = 0 + if record_history: + self._record_history(url) + def _read_response_body(self, response: Any, deadline: float, timeout: float) -> bytes: """Read a bounded response while enforcing the navigation's monotonic deadline.""" body = bytearray() @@ -403,14 +430,20 @@ def _record_history(self, url: str): def back(self) -> Dict[str, Any]: if self.history_index > 0: - self.history_index -= 1 - return self.open(self.history[self.history_index], record_history=False) + target_index = self.history_index - 1 + result = self.open(self.history[target_index], record_history=False) + if result.get("status") != "error": + self.history_index = target_index + return result return self._build_status() def forward(self) -> Dict[str, Any]: if self.history_index < len(self.history) - 1: - self.history_index += 1 - return self.open(self.history[self.history_index], record_history=False) + target_index = self.history_index + 1 + result = self.open(self.history[target_index], record_history=False) + if result.get("status") != "error": + self.history_index = target_index + return result return self._build_status() def reload(self) -> Dict[str, Any]: @@ -419,21 +452,34 @@ def reload(self) -> Dict[str, Any]: return self._build_status() def _parse_and_layout(self): + dom_root, elements_by_id, document_height, page_title = self._layout_document( + self.url, self.page_html + ) + self.dom_root = dom_root + self.elements_by_id = elements_by_id + self.document_height = document_height + self.page_title = page_title + self.active_element_id = None + + def _layout_document( + self, + url: str, + page_html: str, + ) -> Tuple[DOMElement, Dict[str, DOMElement], int, str]: parser = SimpleDOMParser() try: - parser.feed(self.page_html) + parser.feed(page_html) except BrowserDocumentTooLargeError: raise except Exception: pass - self.dom_root = parser.root - self.elements_by_id.clear() - self.active_element_id = None + dom_root = parser.root + elements_by_id: Dict[str, DOMElement] = {} # Extract title - title_match = re.search(r"(.*?)", self.page_html, re.IGNORECASE | re.DOTALL) - self.page_title = title_match.group(1).strip() if title_match else self.url + title_match = re.search(r"(.*?)", page_html, re.IGNORECASE | re.DOTALL) + page_title = title_match.group(1).strip() if title_match else url # Compute layout boxes current_y = 20 @@ -444,7 +490,7 @@ def traverse(root: DOMElement): stack = [root] while stack: node = stack.pop() - self.elements_by_id[node.element_id] = node + elements_by_id[node.element_id] = node all_elems.append(node) if node.tag in ("h1", "h2", "h3", "p", "div", "button", "input", "a", "section"): @@ -456,8 +502,9 @@ def traverse(root: DOMElement): stack.extend(reversed(node.children)) - traverse(self.dom_root) - self.document_height = max(self.viewport_height, current_y + 40) + traverse(dom_root) + document_height = max(self.viewport_height, current_y + 40) + return dom_root, elements_by_id, document_height, page_title def scroll(self, delta_y: int) -> Dict[str, Any]: self.scroll_y = max(0, min(self.scroll_y + delta_y, self.document_height - self.viewport_height)) @@ -615,9 +662,15 @@ def _capture_layers(self, total_layers: int, start_top: int) -> Dict[str, Any]: } def _build_status(self) -> Dict[str, Any]: - elements_summary = [ + actionable_tags = ("a", "button", "input", "textarea") + informational_tags = ("h1", "h2", "h3", "form") + actionable_elements = [ + elem.to_dict() for elem in self.elements_by_id.values() + if elem.tag in actionable_tags + ] + informational_elements = [ elem.to_dict() for elem in self.elements_by_id.values() - if elem.tag in ("a", "button", "input", "textarea", "h1", "h2", "h3", "form") + if elem.tag in informational_tags ] return { "session_id": self.session_id, @@ -626,7 +679,7 @@ def _build_status(self) -> Dict[str, Any]: "viewport": [self.viewport_width, self.viewport_height], "scroll_y": self.scroll_y, "document_height": self.document_height, - "interactive_elements": elements_summary[:50], + "interactive_elements": (actionable_elements + informational_elements)[:50], } diff --git a/fable_engine/server.py b/fable_engine/server.py index 4ddcce2..a61de26 100644 --- a/fable_engine/server.py +++ b/fable_engine/server.py @@ -312,6 +312,12 @@ def main(): if tool_name == "browser_close": res = GLOBAL_BROWSER_ENGINE.close_session(sid) + elif tool_name == "browser_wait": + sec = float(arguments.get("seconds", 1.0)) + import time + bounded_sec = min(max(sec, 0), 10) + time.sleep(bounded_sec) + res = {"status": "waited", "seconds": bounded_sec} else: session = GLOBAL_BROWSER_ENGINE.get_or_create_session(sid) @@ -342,11 +348,7 @@ def main(): elif tool_name == "browser_reload": res = session.reload() elif tool_name == "browser_wait": - sec = float(arguments.get("seconds", 1.0)) - import time - bounded_sec = min(max(sec, 0), 10) - time.sleep(bounded_sec) - res = {"status": "waited", "seconds": bounded_sec} + pass elif tool_name == "browser_press": key = str(arguments.get("key", "")) elem_id = arguments.get("element_id") @@ -355,10 +357,7 @@ def main(): raise ValueError(f"Unknown browser tool action: {tool_name}") is_error = ( - tool_name in ( - "browser_open", "browser_navigate", "browser_click", "browser_type" - ) - and isinstance(res, dict) + isinstance(res, dict) and (res.get("status") == "error" or "error" in res) ) send_response({ diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index 1561cc0..8f3355e 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -246,6 +246,79 @@ def test_oversized_dom_returns_an_explicit_error(self): self.assertIn(f"{MAX_DOM_NODES} DOM node limit", result["error"]) self.assertLess(len(session.elements_by_id), MAX_DOM_NODES) + def test_failed_navigation_preserves_previous_page_state(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.open("about:blank") + session.scroll_y = 275 + previous_state = ( + session.url, + session.page_html, + session.dom_root, + session.elements_by_id, + session.page_title, + session.document_height, + list(session.history), + session.history_index, + session.scroll_y, + ) + + session.opener = Mock() + session.opener.open.side_effect = OSError("navigation failed") + result = session.open("example.test") + + self.assertEqual(result["status"], "error") + self.assertEqual(result["url"], "https://example.test") + self.assertEqual( + ( + session.url, + session.page_html, + session.dom_root, + session.elements_by_id, + session.page_title, + session.document_height, + session.history, + session.history_index, + session.scroll_y, + ), + previous_state, + ) + + def test_layout_failure_preserves_previous_page_state(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.open("about:blank") + session.scroll_y = 275 + previous_state = ( + session.url, + session.page_html, + session.dom_root, + session.elements_by_id, + session.page_title, + session.document_height, + list(session.history), + session.history_index, + session.scroll_y, + ) + + result = session.open(f"http://127.0.0.1:{self.port}/oversized-dom") + + self.assertEqual(result["status"], "error") + self.assertEqual( + ( + session.url, + session.page_html, + session.dom_root, + session.elements_by_id, + session.page_title, + session.document_height, + session.history, + session.history_index, + session.scroll_y, + ), + previous_state, + ) + def test_navigation_resets_scroll_before_layout(self): pm = ProfileManager(profile_dir=self.profile_dir) session = StealthBrowserSession("test_tab", pm) @@ -364,6 +437,39 @@ def test_url_scheme_checks_are_case_insensitive_without_rewriting_url(self): request.assert_called_once_with("HtTp://Example.test/Path") + def test_bare_urls_use_https_except_for_loopback_targets(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.opener = Mock() + session.opener.open.side_effect = OSError("stop before network access") + + for target, expected in ( + ("example.test/path", "https://example.test/path"), + ("localhost:3000/path", "http://localhost:3000/path"), + ("127.0.0.1:3000/path", "http://127.0.0.1:3000/path"), + ("[::1]:3000/path", "http://[::1]:3000/path"), + ("http://example.test/path", "http://example.test/path"), + ): + with self.subTest(target=target): + session.open(target) + self.assertEqual(session.opener.open.call_args.args[0].full_url, expected) + + def test_actionable_elements_are_not_hidden_by_summary_limit(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.page_html = ( + "" + + "".join(f"

Heading {index}

" for index in range(60)) + + '' + + "" + ) + session._parse_and_layout() + + elements = session._build_status()["interactive_elements"] + + self.assertEqual(elements[0]["element_id"], "late-input") + self.assertLessEqual(len(elements), 50) + def test_layout_walk_handles_deep_dom_in_preorder(self): pm = ProfileManager(profile_dir=self.profile_dir) session = StealthBrowserSession("test_tab", pm) @@ -551,6 +657,7 @@ def test_browser_operation_error_results_set_mcp_error_flag(self): "status": "error", "error": "not editable", } + session.scroll.return_value = {"error": "scroll failed"} requests = "".join( json.dumps({ "jsonrpc": "2.0", @@ -562,6 +669,7 @@ def test_browser_operation_error_results_set_mcp_error_flag(self): ("browser_open", {"url": "bad.test"}), ("browser_click", {"element_id": "missing"}), ("browser_type", {"element_id": "readonly", "text": "value"}), + ("browser_scroll", {"delta_y": 10}), ("browser_open", {"url": "about:blank"}), ), start=1) ) @@ -578,7 +686,7 @@ def test_browser_operation_error_results_set_mcp_error_flag(self): responses = [json.loads(line) for line in output.getvalue().splitlines()] self.assertEqual( [response["result"]["isError"] for response in responses], - [True, True, True, False], + [True, True, True, True, False], ) def test_browser_open_enforces_one_deadline_across_reads(self): @@ -626,10 +734,12 @@ def read(self, _size): def test_browser_wait_reports_bounded_duration(self): engine = Mock() - engine.get_or_create_session.return_value = Mock() request = json.dumps({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "browser_wait", "arguments": {"seconds": 25}}, + "params": { + "name": "browser_wait", + "arguments": {"seconds": 25, "session_id": "new-session"}, + }, }) + "\n" output = io.StringIO() with ( @@ -643,6 +753,8 @@ def test_browser_wait_reports_bounded_duration(self): browser_server.main() sleep.assert_called_once_with(10) + engine.get_or_create_session.assert_not_called() + engine.close_session.assert_not_called() result = json.loads(json.loads(output.getvalue())["result"]["content"][0]["text"]) self.assertEqual(result["seconds"], 10) From 3c8ae6f6a24dbee5fb9ca2628c13e230f2f23f18 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:27:56 +0000 Subject: [PATCH 10/12] fix: harden browser redirects, reload history, and memory limits --- fable_engine/browser.py | 21 +++++++++++-- fable_engine/schema.py | 4 +-- tests/test_stealth_browser.py | 59 ++++++++++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 30a97d4..681f6b8 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -32,7 +32,7 @@ DEFAULT_VIEWPORT_WIDTH = 1280 DEFAULT_VIEWPORT_HEIGHT = 800 MAX_BROWSER_SESSIONS = 32 -MAX_RETAINED_PAGE_HTML_BYTES = 32 * 1024 * 1024 +MAX_RETAINED_PAGE_HTML_BYTES = 16 * 1024 * 1024 DEFAULT_MAX_RESPONSE_BYTES = MAX_RETAINED_PAGE_HTML_BYTES // MAX_BROWSER_SESSIONS DEFAULT_BROWSER_OPEN_TIMEOUT_SECONDS = 15.0 MAX_BROWSER_OPEN_TIMEOUT_SECONDS = 30.0 @@ -92,6 +92,19 @@ class BrowserDocumentTooLargeError(ValueError): """Raised when a page exceeds the browser's bounded DOM size.""" +class NoHTTPSDowngradeRedirectHandler(urllib.request.HTTPRedirectHandler): + """Follow normal HTTP redirects except HTTPS-to-HTTP downgrades.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + source_scheme = urllib.parse.urlsplit(req.full_url).scheme.lower() + target_scheme = urllib.parse.urlsplit( + urllib.parse.urljoin(req.full_url, newurl) + ).scheme.lower() + if source_scheme == "https" and target_scheme == "http": + return None + return super().redirect_request(req, fp, code, msg, headers, newurl) + + class SimpleDOMParser(HTMLParser): """HTML parser that constructs a clean DOM tree with stable element IDs.""" @@ -290,7 +303,8 @@ def __init__( self.viewport_height = viewport_height self.max_response_bytes = max(1, int(max_response_bytes)) self.opener = urllib.request.build_opener( - urllib.request.HTTPCookieProcessor(self.profile_manager.cookies) + urllib.request.HTTPCookieProcessor(self.profile_manager.cookies), + NoHTTPSDowngradeRedirectHandler(), ) self.url = "about:blank" self.history: List[str] = [] @@ -448,7 +462,7 @@ def forward(self) -> Dict[str, Any]: def reload(self) -> Dict[str, Any]: if self.url and not self.url.startswith("about:"): - return self.open(self.url) + return self.open(self.url, record_history=False) return self._build_status() def _parse_and_layout(self): @@ -511,6 +525,7 @@ def scroll(self, delta_y: int) -> Dict[str, Any]: return self._build_status() def click(self, element_id: str) -> Dict[str, Any]: + """Follow an element's link target; non-link controls are unsupported.""" elem = self.elements_by_id.get(element_id) if not elem: return {"error": f"Element '{element_id}' not found"} diff --git a/fable_engine/schema.py b/fable_engine/schema.py index bff2469..9bd1b19 100644 --- a/fable_engine/schema.py +++ b/fable_engine/schema.py @@ -590,11 +590,11 @@ }, { "name": "browser_click", - "description": "Clicks an interactive element by its stable element ID.", + "description": "Navigates to the href of a link element by its stable element ID. Elements without an href are unsupported.", "inputSchema": { "type": "object", "properties": { - "element_id": {"type": "string", "description": "Stable element ID to click."}, + "element_id": {"type": "string", "description": "Stable element ID of an href-bearing link."}, "session_id": {"type": "string", "description": "Optional browser tab/session identifier."} }, "required": ["element_id"] diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index 8f3355e..ec435f0 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -19,7 +19,10 @@ import tempfile import threading import unittest +import urllib.request +import urllib.response import zlib +from email.message import Message from unittest.mock import Mock, patch from fable_engine.browser import ( @@ -44,6 +47,8 @@ class _BrowserTestHandler(http.server.BaseHTTPRequestHandler): redirect_history_one = False + downgrade_requests = 0 + downgrade_cookie = None def do_GET(self): if self.path == "/set-cookie": @@ -61,6 +66,11 @@ def do_GET(self): self.send_header("Location", "/redirected") self.end_headers() return + elif self.path == "/downgrade-target": + type(self).downgrade_requests += 1 + type(self).downgrade_cookie = self.headers.get("Cookie") + self.send_response(200) + body = b"downgrade followed" else: self.send_response(200) body = (self.headers.get("Cookie") or "").encode("utf-8") @@ -79,6 +89,8 @@ def setUp(self): self.tmp_dir = tempfile.mkdtemp() self.profile_dir = os.path.join(self.tmp_dir, "browser-profile") _BrowserTestHandler.redirect_history_one = False + _BrowserTestHandler.downgrade_requests = 0 + _BrowserTestHandler.downgrade_cookie = None self.httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _BrowserTestHandler) self.http_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) self.http_thread.start() @@ -121,6 +133,13 @@ def test_browser_schemas_exist(self): self.assertEqual(navigate_timeout_schema["maximum"], MAX_BROWSER_OPEN_TIMEOUT_SECONDS) wait_schema = schemas["browser_wait"]["properties"]["seconds"] self.assertEqual(wait_schema["maximum"], 10) + click_schema = next( + tool for tool in BROWSER_TOOL_SCHEMAS if tool["name"] == "browser_click" + ) + self.assertIn("href", click_schema["description"]) + self.assertIn( + "href", click_schema["inputSchema"]["properties"]["element_id"]["description"] + ) def test_profile_manager_persistence(self): pm = ProfileManager(profile_dir=self.profile_dir) @@ -164,6 +183,39 @@ def test_cookie_jar_does_not_send_cookie_to_another_origin(self): self.assertNotIn("session_token", session.page_html) + def test_https_cookie_redirect_to_http_is_not_followed(self): + class FakeHTTPSRedirectHandler(urllib.request.BaseHandler): + handler_order = 100 + + def https_open(inner_self, request): + headers = Message() + headers.add_header( + "Location", f"http://localhost:{self.port}/downgrade-target" + ) + headers.add_header( + "Set-Cookie", "session_token=should-not-leak; Path=/" + ) + body = io.BytesIO() + body.msg = headers + return urllib.response.addinfourl( + body, headers, request.full_url, 302 + ) + + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.opener.add_handler(FakeHTTPSRedirectHandler()) + + result = session.open(f"https://localhost:{self.port}/redirect") + + self.assertEqual(result["status"], "error") + self.assertIn("HTTP Error 302", result["error"]) + self.assertEqual( + {cookie.name: cookie.value for cookie in pm.cookies}["session_token"], + "should-not-leak", + ) + self.assertEqual(_BrowserTestHandler.downgrade_requests, 0) + self.assertIsNone(_BrowserTestHandler.downgrade_cookie) + def test_browser_session_about_blank(self): pm = ProfileManager(profile_dir=self.profile_dir) session = StealthBrowserSession("test_tab", pm) @@ -398,6 +450,11 @@ def test_history_traversal_does_not_mutate_history_on_redirect(self): self.assertEqual(session.history_index, 0) self.assertTrue(session.url.endswith("/redirected")) + session.reload() + self.assertEqual(session.history, original_history) + self.assertEqual(session.history_index, 0) + self.assertTrue(session.url.endswith("/redirected")) + session.forward() self.assertEqual(session.history, original_history) self.assertEqual(session.history_index, 1) @@ -541,7 +598,7 @@ def test_default_session_page_retention_has_a_safe_aggregate_limit(self): DEFAULT_MAX_RESPONSE_BYTES * MAX_BROWSER_SESSIONS, MAX_RETAINED_PAGE_HTML_BYTES, ) - self.assertLess(MAX_RETAINED_PAGE_HTML_BYTES, 160 * 1024 * 1024) + self.assertLess(MAX_RETAINED_PAGE_HTML_BYTES, 20 * 1024 * 1024) def test_browser_module_import_does_not_create_profile(self): script = """ From ed4bea92153638b01c45e0bcfd668ff74f03bae0 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:59:16 +0000 Subject: [PATCH 11/12] fix: prevent persistent cookie leakage over remote HTTP --- fable_engine/browser.py | 75 ++++++++++++++++++++--------- tests/test_stealth_browser.py | 91 ++++++++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index 681f6b8..c9f4450 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -105,6 +105,37 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return super().redirect_request(req, fp, code, msg, headers, newurl) +def _is_loopback_target(url: str) -> bool: + """Return whether a URL or scheme-less target names the local machine.""" + try: + parsed = urllib.parse.urlsplit(url) + if parsed.scheme.lower() in ("http", "https"): + hostname = parsed.hostname + else: + hostname = urllib.parse.urlsplit(f"//{url}").hostname + except ValueError: + return False + if not hostname: + return False + hostname = hostname.rstrip(".").lower() + if hostname == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +class NoRemoteHTTPCookiePolicy(http.cookiejar.DefaultCookiePolicy): + """Never return persistent cookies over remote cleartext HTTP.""" + + def return_ok(self, cookie, request): + parsed = urllib.parse.urlsplit(request.full_url) + if parsed.scheme.lower() == "http" and not _is_loopback_target(request.full_url): + return False + return super().return_ok(cookie, request) + + class SimpleDOMParser(HTMLParser): """HTML parser that constructs a clean DOM tree with stable element IDs.""" @@ -226,7 +257,9 @@ def __init__(self, profile_dir: Optional[pathlib.Path | str] = None): self.profile_dir.chmod(0o700) self.cookie_file = self.profile_dir / "cookies.txt" self.storage_file = self.profile_dir / "local_storage.json" - self.cookies = http.cookiejar.MozillaCookieJar(str(self.cookie_file)) + self.cookies = http.cookiejar.MozillaCookieJar( + str(self.cookie_file), policy=NoRemoteHTTPCookiePolicy() + ) self.local_storage: Dict[str, Any] = {} self._load() @@ -306,6 +339,9 @@ def __init__( urllib.request.HTTPCookieProcessor(self.profile_manager.cookies), NoHTTPSDowngradeRedirectHandler(), ) + self.credential_free_opener = urllib.request.build_opener( + NoHTTPSDowngradeRedirectHandler() + ) self.url = "about:blank" self.history: List[str] = [] self.history_index = -1 @@ -347,7 +383,10 @@ def open( remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError(f"Browser navigation timed out after {timeout:g} seconds") - with self.opener.open(req, timeout=remaining) as resp: + opener = self.opener + if scheme == "http" and not self._is_loopback_target(url): + opener = self.credential_free_opener + with opener.open(req, timeout=remaining) as resp: body_bytes = self._read_response_body(resp, deadline, timeout) self.profile_manager.save() if len(body_bytes) > self.max_response_bytes: @@ -366,20 +405,7 @@ def open( @staticmethod def _is_loopback_target(url: str) -> bool: - """Return whether a scheme-less URL targets the local machine.""" - try: - hostname = urllib.parse.urlsplit(f"//{url}").hostname - except ValueError: - return False - if not hostname: - return False - hostname = hostname.rstrip(".").lower() - if hostname == "localhost" or hostname.endswith(".localhost"): - return True - try: - return ipaddress.ip_address(hostname).is_loopback - except ValueError: - return False + return _is_loopback_target(url) def _commit_navigation( self, @@ -564,13 +590,16 @@ def close(self) -> None: try: self.opener.close() finally: - self.history.clear() - self.history_index = -1 - self.dom_root = None - self.elements_by_id.clear() - self.page_html = "" - self.page_title = "" - self.active_element_id = None + try: + self.credential_free_opener.close() + finally: + self.history.clear() + self.history_index = -1 + self.dom_root = None + self.elements_by_id.clear() + self.page_html = "" + self.page_title = "" + self.active_element_id = None def press_key(self, key: str, element_id: Optional[str] = None) -> Dict[str, Any]: if not key: diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index ec435f0..59f1beb 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -183,6 +183,81 @@ def test_cookie_jar_does_not_send_cookie_to_another_origin(self): self.assertNotIn("session_token", session.page_html) + def test_remote_http_never_receives_persistent_cookies(self): + pm = ProfileManager(profile_dir=self.profile_dir) + pm.cookies.set_cookie(http.cookiejar.Cookie( + version=0, name="session_token", value="abc123secret", + port=None, port_specified=False, domain="example.test", + domain_specified=False, domain_initial_dot=False, path="/", + path_specified=True, secure=False, expires=None, discard=False, + comment=None, comment_url=None, rest={}, rfc2109=False, + )) + + cleartext_request = urllib.request.Request("http://example.test/path") + pm.cookies.add_cookie_header(cleartext_request) + secure_request = urllib.request.Request("https://example.test/path") + pm.cookies.add_cookie_header(secure_request) + + self.assertIsNone(cleartext_request.get_header("Cookie")) + self.assertEqual(secure_request.get_header("Cookie"), "session_token=abc123secret") + + def test_remote_http_uses_credential_free_opener_for_redirect_chain(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + session.opener = Mock() + session.credential_free_opener = Mock() + session.credential_free_opener.open.side_effect = OSError( + "stop before network access" + ) + + session.open("http://example.test/path") + + session.opener.open.assert_not_called() + request = session.credential_free_opener.open.call_args.args[0] + self.assertEqual(request.full_url, "http://example.test/path") + + def test_http_redirect_to_remote_target_does_not_receive_cookie(self): + class FakeHTTPRedirectHandler(urllib.request.BaseHandler): + handler_order = 100 + + def __init__(inner_self): + inner_self.requests = [] + + def http_open(inner_self, request): + inner_self.requests.append(request) + headers = Message() + if request.full_url.startswith("http://localhost/"): + headers.add_header("Location", "http://example.test/final") + body = io.BytesIO() + body.msg = headers + return urllib.response.addinfourl( + body, headers, request.full_url, 302 + ) + body = io.BytesIO(b"redirect complete") + headers.add_header("Content-Type", "text/html; charset=utf-8") + body.msg = headers + return urllib.response.addinfourl( + body, headers, request.full_url, 200 + ) + + pm = ProfileManager(profile_dir=self.profile_dir) + pm.cookies.set_cookie(http.cookiejar.Cookie( + version=0, name="session_token", value="abc123secret", + port=None, port_specified=False, domain="example.test", + domain_specified=False, domain_initial_dot=False, path="/", + path_specified=True, secure=False, expires=None, discard=False, + comment=None, comment_url=None, rest={}, rfc2109=False, + )) + session = StealthBrowserSession("test_tab", pm) + handler = FakeHTTPRedirectHandler() + session.opener.add_handler(handler) + + result = session.open("http://localhost/start") + + self.assertEqual(result["url"], "http://example.test/final") + self.assertEqual(len(handler.requests), 2) + self.assertIsNone(handler.requests[1].get_header("Cookie")) + def test_https_cookie_redirect_to_http_is_not_followed(self): class FakeHTTPSRedirectHandler(urllib.request.BaseHandler): handler_order = 100 @@ -498,18 +573,30 @@ def test_bare_urls_use_https_except_for_loopback_targets(self): pm = ProfileManager(profile_dir=self.profile_dir) session = StealthBrowserSession("test_tab", pm) session.opener = Mock() + session.credential_free_opener = Mock() session.opener.open.side_effect = OSError("stop before network access") + session.credential_free_opener.open.side_effect = OSError( + "stop before network access" + ) for target, expected in ( ("example.test/path", "https://example.test/path"), ("localhost:3000/path", "http://localhost:3000/path"), + ("service.localhost:3000/path", "https://service.localhost:3000/path"), ("127.0.0.1:3000/path", "http://127.0.0.1:3000/path"), ("[::1]:3000/path", "http://[::1]:3000/path"), ("http://example.test/path", "http://example.test/path"), ): with self.subTest(target=target): + session.opener.reset_mock() + session.credential_free_opener.reset_mock() session.open(target) - self.assertEqual(session.opener.open.call_args.args[0].full_url, expected) + opener = ( + session.credential_free_opener + if expected.startswith("http://example.test") + else session.opener + ) + self.assertEqual(opener.open.call_args.args[0].full_url, expected) def test_actionable_elements_are_not_hidden_by_summary_limit(self): pm = ProfileManager(profile_dir=self.profile_dir) @@ -585,12 +672,14 @@ def test_browser_engine_session_lifecycle(self): session2.page_html = "retained page" session2.opener.close = Mock() + session2.credential_free_opener.close = Mock() engine.get_or_create_session("tab3") engine.get_or_create_session("tab4") self.assertNotIn("tab2", engine.sessions) self.assertEqual(list(engine.sessions), ["tab3", "tab4"]) self.assertLessEqual(len(engine.sessions), MAX_BROWSER_SESSIONS) session2.opener.close.assert_called_once_with() + session2.credential_free_opener.close.assert_called_once_with() self.assertEqual(session2.page_html, "") def test_default_session_page_retention_has_a_safe_aggregate_limit(self): From d2c96c6ba8cf4bdabe08f78a7974c8f8dd4e4b21 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:21:48 +0000 Subject: [PATCH 12/12] fix: harden redirect deadlines, history branching, and key handling --- fable_engine/browser.py | 60 ++++++++++++++++++++++++++-------- fable_engine/schema.py | 2 +- tests/test_stealth_browser.py | 61 +++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/fable_engine/browser.py b/fable_engine/browser.py index c9f4450..52e766a 100644 --- a/fable_engine/browser.py +++ b/fable_engine/browser.py @@ -102,7 +102,26 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): ).scheme.lower() if source_scheme == "https" and target_scheme == "http": return None - return super().redirect_request(req, fp, code, msg, headers, newurl) + redirected = super().redirect_request(req, fp, code, msg, headers, newurl) + if redirected is not None: + for attr in ("_fable_navigation_deadline", "_fable_navigation_timeout"): + if hasattr(req, attr): + setattr(redirected, attr, getattr(req, attr)) + return redirected + + def http_error_302(self, req, fp, code, msg, headers): + deadline = getattr(req, "_fable_navigation_deadline", None) + if deadline is not None: + timeout = getattr(req, "_fable_navigation_timeout", req.timeout) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"Browser navigation timed out after {timeout:g} seconds" + ) + req.timeout = remaining + return super().http_error_302(req, fp, code, msg, headers) + + http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302 def _is_loopback_target(url: str) -> bool: @@ -379,6 +398,8 @@ def open( req.add_header("Accept-Language", "en-US,en;q=0.9") deadline = time.monotonic() + timeout + req._fable_navigation_deadline = deadline + req._fable_navigation_timeout = timeout try: remaining = deadline - time.monotonic() if remaining <= 0: @@ -458,9 +479,9 @@ def _set_response_socket_timeout(response: Any, timeout: float) -> None: def _record_history(self, url: str): if self.history_index >= 0 and self.history_index < len(self.history): + self.history = self.history[: self.history_index + 1] if self.history[self.history_index] == url: return - self.history = self.history[: self.history_index + 1] self.history.append(url) self.history_index = len(self.history) - 1 overflow = len(self.history) - MAX_HISTORY_ENTRIES @@ -615,12 +636,16 @@ def press_key(self, key: str, element_id: Optional[str] = None) -> Dict[str, Any elem.element_id for elem in self.elements_by_id.values() if elem.tag in ("a", "button", "input", "textarea") ] - if focusable: - try: - current_index = focusable.index(self.active_element_id) - except ValueError: - current_index = -1 - self.active_element_id = focusable[(current_index + 1) % len(focusable)] + if not focusable: + return { + "status": "error", + "error": "Tab is unsupported because the page has no focusable elements", + } + try: + current_index = focusable.index(self.active_element_id) + except ValueError: + current_index = -1 + self.active_element_id = focusable[(current_index + 1) % len(focusable)] return { "status": "pressed", "key": key, @@ -640,18 +665,27 @@ def press_key(self, key: str, element_id: Optional[str] = None) -> Dict[str, Any ) if is_editable: current_value = target.attrs.get("value", "") + updated_value: Optional[str] = None if key == "Backspace": - target.attrs["value"] = current_value[:-1] + updated_value = current_value[:-1] elif key == "Space": - target.attrs["value"] = current_value + " " + updated_value = current_value + " " elif key == "Enter" and target.tag == "textarea": - target.attrs["value"] = current_value + "\n" + updated_value = current_value + "\n" elif len(key) == 1: - target.attrs["value"] = current_value + key + updated_value = current_value + key + if updated_value is not None and updated_value != current_value: + target.attrs["value"] = updated_value + return {"status": "pressed", "key": key, "element_id": target_id} elif key == "Enter" and target.attrs.get("href") is not None: return self.click(target.element_id) - return {"status": "pressed", "key": key, "element_id": target_id} + target_description = f"<{target.tag}>" if target is not None else "the active window" + return { + "status": "error", + "error": f"Key '{key}' is unsupported for {target_description}", + "element_id": target_id, + } def snapshot_layers(self, max_layers: int = 3) -> Dict[str, Any]: """Captures N viewport-sized sequential screenshots (1 layer = 1 viewport height).""" diff --git a/fable_engine/schema.py b/fable_engine/schema.py index 9bd1b19..43f87ef 100644 --- a/fable_engine/schema.py +++ b/fable_engine/schema.py @@ -690,7 +690,7 @@ }, { "name": "browser_press", - "description": "Presses a keyboard key on an element or active window.", + "description": "Edits supported text controls, moves focus with Tab, or activates links with Enter.", "inputSchema": { "type": "object", "properties": { diff --git a/tests/test_stealth_browser.py b/tests/test_stealth_browser.py index 59f1beb..37817cd 100644 --- a/tests/test_stealth_browser.py +++ b/tests/test_stealth_browser.py @@ -118,6 +118,12 @@ def test_browser_schemas_exist(self): self.assertIn("browser_press", tool_names) self.assertIn("browser_reload", tool_names) + press_schema = next( + schema for schema in BROWSER_TOOL_SCHEMAS + if schema["name"] == "browser_press" + ) + self.assertNotIn("active window", press_schema["description"]) + for tool in BROWSER_TOOL_SCHEMAS: self.assertTrue(len(tool["description"]) > 0) self.assertIn("type", tool["inputSchema"]) @@ -258,6 +264,44 @@ def http_open(inner_self, request): self.assertEqual(len(handler.requests), 2) self.assertIsNone(handler.requests[1].get_header("Cookie")) + def test_redirect_hops_receive_remaining_navigation_deadline(self): + class DelayedRedirectHandler(urllib.request.BaseHandler): + handler_order = 100 + + def __init__(inner_self): + inner_self.timeouts = [] + + def http_open(inner_self, request): + inner_self.timeouts.append(request.timeout) + headers = Message() + headers.add_header("Location", f"/hop-{len(inner_self.timeouts)}") + body = io.BytesIO() + body.msg = headers + return urllib.response.addinfourl( + body, headers, request.full_url, 302 + ) + + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + handler = DelayedRedirectHandler() + session.opener.add_handler(handler) + + with ( + patch( + "fable_engine.browser.time.monotonic", + side_effect=(100.0, 100.1, 100.4, 101.1), + ), + patch.object(session, "_read_response_body") as read_response_body, + ): + result = session.open("http://localhost/start", timeout=1.0) + + self.assertEqual(result["status"], "error") + self.assertIn("timed out", result["error"]) + self.assertEqual(len(handler.timeouts), 2) + self.assertAlmostEqual(handler.timeouts[0], 0.9) + self.assertAlmostEqual(handler.timeouts[1], 0.6) + read_response_body.assert_not_called() + def test_https_cookie_redirect_to_http_is_not_followed(self): class FakeHTTPSRedirectHandler(urllib.request.BaseHandler): handler_order = 100 @@ -347,6 +391,10 @@ def test_dom_parsing_and_element_interaction(self): session.elements_by_id["username_input"].attrs["value"], "agent_user" ) self.assertEqual(session.press_key("Tab", "username_input")["element_id"], "remember_me") + self.assertEqual(session.press_key("Enter", "username_input")["status"], "error") + self.assertEqual(session.press_key("Enter", "login_btn")["status"], "error") + self.assertEqual(session.press_key("Enter", "notes")["status"], "pressed") + self.assertTrue(session.elements_by_id["notes"].attrs["value"].endswith("\n")) # Test click link click_res = session.click("home_link") @@ -555,6 +603,19 @@ def test_history_is_bounded_and_remains_consistent_after_branching(self): self.assertEqual(session.history_index, len(session.history) - 1) self.assertEqual(session.forward()["url"], "about:branched") + def test_same_url_navigation_clears_forward_history(self): + pm = ProfileManager(profile_dir=self.profile_dir) + session = StealthBrowserSession("test_tab", pm) + for url in ("about:A", "about:B", "about:C"): + session.open(url) + + session.back() + session.open("about:B") + + self.assertEqual(session.history, ["about:A", "about:B"]) + self.assertEqual(session.history_index, 1) + self.assertEqual(session.forward()["url"], "about:B") + def test_url_scheme_checks_are_case_insensitive_without_rewriting_url(self): pm = ProfileManager(profile_dir=self.profile_dir) session = StealthBrowserSession("test_tab", pm)