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 PageBlank 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 PageBlank 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 PageBlank 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 PageBlank 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 PageBlank Page
"
- self.scroll_y = 0
- self._parse_and_layout()
- if record_history:
- self._record_history(url)
+ page_html = "Blank PageBlank 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)