"""Snapshot post-processing for the browser tools: truncate-and-store of oversized accessibility trees, model-boundary secret redaction, screenshot-path recovery. Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``) so ``patch("tools.browser_tool.X")`` keeps working; never import ``tools.browser_tool`` at import time (cycle). """ import re from typing import Any, Optional from tools.browser_tool_origin import origin_module as _origin def _extract_screenshot_path_from_text(text: str) -> Optional[str]: """Extract a screenshot file path from agent-browser human-readable output.""" if not text: return None patterns = [ r"Screenshot saved to ['\"](?P/[^'\"]+?\.png)['\"]", r"Screenshot saved to (?P/\S+?\.png)(?:\s|$)", r"(?P/\S+?\.png)(?:\s|$)", ] for pattern in patterns: match = re.search(pattern, text) if match: path = match.group("path").strip().strip("'\"") if path: return path return None def _store_full_snapshot(snapshot_text: str) -> Optional[str]: """Write a full snapshot to cache/web and return its path (None on failure — best-effort). Mirrors ``web_tools._store_full_text``: cache/web is mounted read-only into remote backends, so read_file can page through the complete tree on any backend. The stored copy is force-redacted (page-rendered secrets must not hit disk unmasked) and named by content hash so identical snapshots dedupe. """ _bt = _origin() try: import hashlib from hermes_constants import get_hermes_dir from agent.redact import redact_sensitive_text content = redact_sensitive_text(snapshot_text, force=True) if len(content) > _bt.MAX_STORED_SNAPSHOT_CHARS: content = ( content[:_bt.MAX_STORED_SNAPSHOT_CHARS] + f"\n\n[... stored copy truncated at {_bt.MAX_STORED_SNAPSHOT_CHARS:,} chars " f"of {len(content):,} ...]" ) from tools.spill_safety import ensure_spill_dir, write_text_exclusive cache_dir = get_hermes_dir("cache/web", "web_cache") ensure_spill_dir(cache_dir, private=False) digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:10] path = cache_dir / f"browser-snapshot-{digest}.txt" # Deterministic filename in a well-known dir: refuse symlinks via # lstat-unlink + exclusive create. Re-snapshotting the same page # state legitimately overwrites (same content-hash name). Not # private: cache/web is bind-mounted into remote backends whose # container UID must be able to read it. write_text_exclusive(path, content, private=False, overwrite=True) return str(path) except Exception as exc: # noqa: BLE001 _bt.logger.debug("Failed to store full browser snapshot: %s", exc) return None def _truncate_snapshot(snapshot_text: str, max_chars: Optional[int] = None) -> str: """Truncate a snapshot at line boundaries (never mid-element) to ``max_chars``. Defaults to ``browser.snapshot_threshold``. The full snapshot is stored to cache/web and the appended note tells the agent how to page through it with read_file — element refs beyond the cut are in the file, not lost. """ _bt = _origin() if max_chars is None: max_chars = _bt.get_browser_snapshot_threshold() if len(snapshot_text) <= max_chars: return snapshot_text stored_path = _store_full_snapshot(snapshot_text) lines = snapshot_text.split('\n') result: list[str] = [] chars = 0 # Reserve space for the truncation note (the stored-path variant is the # longer of the two). Clamp so tiny max_chars values still keep content. reserve = min(110 + len(stored_path or ""), max_chars // 2) for line in lines: if chars + len(line) + 1 > max_chars - reserve: break result.append(line) chars += len(line) + 1 remaining = len(lines) - len(result) if remaining > 0: if stored_path: next_line = len(result) + 1 result.append( f'\n[... {remaining} more lines truncated — full snapshot: ' f'read_file path="{stored_path}" offset={next_line} limit=200]' ) else: result.append(f'\n[... {remaining} more lines truncated, use browser_snapshot for full content]') return '\n'.join(result) def _redact_browser_output(value: Any) -> Any: """Redact secrets from browser-originated data before returning to the model. Browser snapshots, console messages, JS exceptions, and eval results can contain page-rendered API keys, cookies, bearer tokens, or pasted secrets. Tool output is a model boundary, so force redaction here even if global log redaction is disabled for debugging. """ from agent.redact import redact_sensitive_text if isinstance(value, str): return redact_sensitive_text(value, force=True) if isinstance(value, list): return [_redact_browser_output(item) for item in value] if isinstance(value, tuple): return tuple(_redact_browser_output(item) for item in value) if isinstance(value, dict): return {key: _redact_browser_output(item) for key, item in value.items()} return value