| """ |
| Browser Automation Tool |
| |
| Provides Chrome DevTools Protocol (CDP) control via Playwright for: |
| - Web scraping and data extraction |
| - Form filling and submission |
| - Multi-step web workflows |
| - Screenshot capture |
| - Browser-based testing |
| - PDF generation |
| - Network interception |
| |
| Governance Integration: |
| - All browser actions require INTERN+ maturity level |
| - Full audit trail via browser_audit table |
| - Agent execution tracking for all browser sessions |
| |
| Refactored to use standardized decorators and service factory. |
| """ |
|
|
| import asyncio |
| import base64 |
| from datetime import datetime |
| from typing import Any, Dict, Optional |
| import uuid |
| from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright |
| from sqlalchemy.orm import Session |
|
|
| from core.agent_context_resolver import AgentContextResolver |
| from core.feature_flags import FeatureFlags |
| from core.models import AgentExecution |
| from core.service_factory import ServiceFactory |
| from core.structured_logger import get_logger |
| import os |
|
|
| logger = get_logger(__name__) |
|
|
| |
| BROWSER_HEADLESS = os.getenv("BROWSER_HEADLESS", "true").lower() == "true" |
|
|
|
|
| class BrowserSession: |
| """ |
| Managed browser session with context and page tracking. |
| |
| Each session represents a unique browser instance that can be |
| reused across multiple operations for maintaining state (cookies, |
| localStorage, etc.). |
| """ |
|
|
| def __init__( |
| self, |
| session_id: str, |
| user_id: str, |
| agent_id: Optional[str] = None, |
| headless: bool = True, |
| browser_type: str = "chromium" |
| ): |
| self.session_id = session_id |
| self.user_id = user_id |
| self.agent_id = agent_id |
| self.headless = headless |
| self.browser_type = browser_type |
| self.playwright: Optional[Playwright] = None |
| self.browser: Optional[Browser] = None |
| self.context: Optional[BrowserContext] = None |
| self.page: Optional[Page] = None |
| self.created_at = datetime.now() |
| self.last_used = datetime.now() |
|
|
| async def start(self): |
| """Start the browser session.""" |
| try: |
| self.playwright = await async_playwright().start() |
|
|
| |
| if self.browser_type == "firefox": |
| self.browser = await self.playwright.firefox.launch(headless=self.headless) |
| elif self.browser_type == "webkit": |
| self.browser = await self.playwright.webkit.launch(headless=self.headless) |
| else: |
| self.browser = await self.playwright.chromium.launch(headless=self.headless) |
|
|
| |
| self.context = await self.browser.new_context( |
| viewport={"width": 1920, "height": 1080}, |
| user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" |
| ) |
|
|
| |
| self.page = await self.context.new_page() |
|
|
| logger.info(f"Browser session {self.session_id} started ({self.browser_type})") |
| return True |
|
|
| except Exception as e: |
| logger.error(f"Failed to start browser session {self.session_id}: {e}") |
| raise |
|
|
| async def close(self): |
| """Close the browser session and cleanup resources.""" |
| try: |
| if self.page: |
| await self.page.close() |
| if self.context: |
| await self.context.close() |
| if self.browser: |
| await self.browser.close() |
| if self.playwright: |
| await self.playwright.stop() |
|
|
| logger.info(f"Browser session {self.session_id} closed") |
| return True |
|
|
| except Exception as e: |
| logger.error(f"Error closing browser session {self.session_id}: {e}") |
| return False |
|
|
|
|
| class BrowserSessionManager: |
| """ |
| Manages active browser sessions with automatic cleanup. |
| |
| Sessions are stored in memory and automatically cleaned up after |
| a timeout period of inactivity. |
| """ |
|
|
| def __init__(self, session_timeout_minutes: int = 30): |
| self.sessions: Dict[str, BrowserSession] = {} |
| self.session_timeout_minutes = session_timeout_minutes |
|
|
| def get_session(self, session_id: str) -> Optional[BrowserSession]: |
| """Get an existing session by ID.""" |
| return self.sessions.get(session_id) |
|
|
| async def create_session( |
| self, |
| user_id: str, |
| agent_id: Optional[str] = None, |
| headless: bool = True, |
| browser_type: str = "chromium" |
| ) -> BrowserSession: |
| """Create and start a new browser session.""" |
| session_id = str(uuid.uuid4()) |
| session = BrowserSession( |
| session_id=session_id, |
| user_id=user_id, |
| agent_id=agent_id, |
| headless=headless, |
| browser_type=browser_type |
| ) |
|
|
| await session.start() |
| self.sessions[session_id] = session |
| return session |
|
|
| async def close_session(self, session_id: str) -> bool: |
| """Close and remove a session.""" |
| session = self.sessions.get(session_id) |
| if session: |
| await session.close() |
| del self.sessions[session_id] |
| return True |
| return False |
|
|
| async def cleanup_expired_sessions(self): |
| """Remove expired sessions based on last used time.""" |
| now = datetime.now() |
| expired_ids = [] |
|
|
| for session_id, session in self.sessions.items(): |
| elapsed = (now - session.last_used).total_seconds() / 60 |
| if elapsed > self.session_timeout_minutes: |
| expired_ids.append(session_id) |
|
|
| for session_id in expired_ids: |
| logger.info(f"Cleaning up expired browser session: {session_id}") |
| await self.close_session(session_id) |
|
|
| return len(expired_ids) |
|
|
|
|
| |
| _session_manager = BrowserSessionManager() |
|
|
|
|
| def get_browser_manager() -> BrowserSessionManager: |
| """Get the global browser session manager.""" |
| return _session_manager |
|
|
|
|
| |
| |
| |
|
|
| async def browser_create_session( |
| user_id: str, |
| agent_id: Optional[str] = None, |
| headless: bool = None, |
| browser_type: str = "chromium", |
| db: Optional[Session] = None |
| ) -> Dict[str, Any]: |
| """ |
| Create a new browser session with governance integration. |
| |
| Args: |
| user_id: User ID creating the session |
| agent_id: Agent ID creating the session (for governance) |
| headless: Whether to run headless (default from env) |
| browser_type: Browser type (chromium, firefox, webkit) |
| db: Database session for governance |
| |
| Returns: |
| Dict with session_id and metadata |
| """ |
| agent = None |
| governance_check = None |
| agent_execution = None |
|
|
| try: |
| |
| if FeatureFlags.should_enforce_governance('browser') and agent_id and db: |
| resolver = AgentContextResolver(db) |
| governance = ServiceFactory.get_governance_service(db) |
|
|
| agent, _ = await resolver.resolve_agent_for_request( |
| user_id=user_id, |
| requested_agent_id=agent_id, |
| action_type="browser_navigate" |
| ) |
|
|
| if agent: |
| governance_check = governance.can_perform_action( |
| agent_id=agent.id, |
| action_type="browser_navigate" |
| ) |
|
|
| if not governance_check["allowed"]: |
| logger.warning(f"Governance blocked browser session: {governance_check['reason']}") |
| return { |
| "success": False, |
| "error": f"Agent not permitted to use browser: {governance_check['reason']}" |
| } |
|
|
| |
| agent_execution = AgentExecution( |
| agent_id=agent.id, |
| workspace_id="default", |
| status="running", |
| input_summary="Create browser session", |
| triggered_by="browser_tool" |
| ) |
| db.add(agent_execution) |
| db.commit() |
| db.refresh(agent_execution) |
|
|
| |
| if headless is None: |
| headless = BROWSER_HEADLESS |
|
|
| session = await get_browser_manager().create_session( |
| user_id=user_id, |
| agent_id=agent_id if agent else None, |
| headless=headless, |
| browser_type=browser_type |
| ) |
|
|
| |
| if agent and db and FeatureFlags.should_enforce_governance('browser'): |
| governance = ServiceFactory.get_governance_service(db) |
| await governance.record_outcome(agent.id, success=True) |
|
|
| if agent_execution: |
| agent_execution.status = "completed" |
| agent_execution.output_summary = f"Created browser session {session.session_id}" |
| agent_execution.completed_at = datetime.now() |
| db.commit() |
|
|
| logger.info(f"Created browser session {session.session_id} for user {user_id}") |
|
|
| return { |
| "success": True, |
| "session_id": session.session_id, |
| "browser_type": browser_type, |
| "headless": headless, |
| "agent_id": agent.id if agent else None, |
| "created_at": session.created_at.isoformat() |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to create browser session: {e}") |
|
|
| if agent_execution and db and FeatureFlags.should_enforce_governance('browser'): |
| try: |
| governance = ServiceFactory.get_governance_service(db) |
| await governance.record_outcome(agent.id, success=False) |
|
|
| agent_execution.status = "failed" |
| agent_execution.error_message = str(e) |
| agent_execution.completed_at = datetime.now() |
| db.commit() |
| except Exception as inner_e: |
| logger.error(f"Failed to record execution failure: {inner_e}") |
|
|
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_navigate( |
| session_id: str, |
| url: str, |
| wait_until: str = "load", |
| user_id: str = None, |
| agent_id: Optional[str] = None, |
| db: Optional[Session] = None |
| ) -> Dict[str, Any]: |
| """ |
| Navigate to a URL in an existing browser session. |
| |
| Args: |
| session_id: Browser session ID |
| url: URL to navigate to |
| wait_until: When to consider navigation succeeded (load, domcontentloaded, networkidle) |
| user_id: User ID for validation |
| agent_id: Agent ID for governance |
| db: Database session for governance |
| |
| Returns: |
| Dict with navigation result and page info |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| |
| response = await session.page.goto(url, wait_until=wait_until, timeout=30000) |
|
|
| session.last_used = datetime.now() |
|
|
| |
| title = await session.page.title() |
| url_final = session.page.url |
|
|
| logger.info(f"Navigated session {session_id} to {url}") |
|
|
| return { |
| "success": True, |
| "session_id": session_id, |
| "url": url_final, |
| "title": title, |
| "status": response.status if response else None, |
| "timestamp": datetime.now().isoformat() |
| } |
|
|
| except Exception as e: |
| logger.error(f"Navigation failed for session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_screenshot( |
| session_id: str, |
| path: Optional[str] = None, |
| full_page: bool = False, |
| user_id: str = None |
| ) -> Dict[str, Any]: |
| """ |
| Take a screenshot of the current page. |
| |
| Args: |
| session_id: Browser session ID |
| path: Optional file path to save screenshot |
| full_page: Whether to capture full scrolling page |
| user_id: User ID for validation |
| |
| Returns: |
| Dict with screenshot data (base64) or file path |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| |
| screenshot_bytes = await session.page.screenshot( |
| full_page=full_page, |
| type="png" |
| ) |
|
|
| session.last_used = datetime.now() |
|
|
| |
| screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8") |
|
|
| |
| if path: |
| import os |
| os.makedirs(os.path.dirname(path) or ".", exist_ok=True) |
| with open(path, "wb") as f: |
| f.write(screenshot_bytes) |
|
|
| logger.info(f"Screenshot saved to {path}") |
| return { |
| "success": True, |
| "path": path, |
| "size_bytes": len(screenshot_bytes) |
| } |
|
|
| logger.info(f"Screenshot taken for session {session_id}") |
|
|
| return { |
| "success": True, |
| "data": screenshot_base64, |
| "size_bytes": len(screenshot_bytes), |
| "format": "png" |
| } |
|
|
| except Exception as e: |
| logger.error(f"Screenshot failed for session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_fill_form( |
| session_id: str, |
| selectors: Dict[str, str], |
| submit: bool = False, |
| user_id: str = None |
| ) -> Dict[str, Any]: |
| """ |
| Fill form fields using CSS selectors. |
| |
| Args: |
| session_id: Browser session ID |
| selectors: Dict mapping CSS selectors to values |
| submit: Whether to submit the form after filling |
| user_id: User ID for validation |
| |
| Returns: |
| Dict with fill result |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| filled_count = 0 |
|
|
| for selector, value in selectors.items(): |
| try: |
| |
| await session.page.wait_for_selector(selector, timeout=5000) |
|
|
| |
| element = await session.page.query_selector(selector) |
| tag_name = await element.evaluate("el => el.tagName") |
| input_type = await element.evaluate("el => el.type || ''") |
|
|
| if tag_name in ["INPUT", "TEXTAREA"]: |
| await session.page.fill(selector, value) |
| filled_count += 1 |
| elif tag_name == "SELECT": |
| await session.page.select_option(selector, value) |
| filled_count += 1 |
| else: |
| logger.warning(f"Unsupported element type: {tag_name} for selector {selector}") |
|
|
| except Exception as e: |
| logger.warning(f"Failed to fill {selector}: {e}") |
|
|
| session.last_used = datetime.now() |
|
|
| result = { |
| "success": True, |
| "session_id": session_id, |
| "fields_filled": filled_count |
| } |
|
|
| |
| if submit: |
| |
| try: |
| |
| submit_button = await session.page.query_selector("button[type='submit']") |
| if submit_button: |
| await submit_button.click() |
| result["submitted"] = True |
| result["submission_method"] = "submit_button" |
| else: |
| |
| await session.page.evaluate("() => document.querySelector('form')?.submit()") |
| result["submitted"] = True |
| result["submission_method"] = "form_submit" |
| except Exception as submit_error: |
| logger.warning(f"Form submission failed: {submit_error}") |
| result["submitted"] = False |
| result["submit_error"] = str(submit_error) |
|
|
| logger.info(f"Filled {filled_count} fields in session {session_id}") |
|
|
| return result |
|
|
| except Exception as e: |
| logger.error(f"Form fill failed for session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_click( |
| session_id: str, |
| selector: str, |
| wait_for: Optional[str] = None, |
| user_id: str = None |
| ) -> Dict[str, Any]: |
| """ |
| Click an element using CSS selector. |
| |
| Args: |
| session_id: Browser session ID |
| selector: CSS selector for element to click |
| wait_for: Optional selector to wait for after click |
| user_id: User ID for validation |
| |
| Returns: |
| Dict with click result |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| |
| await session.page.wait_for_selector(selector, state="visible", timeout=5000) |
|
|
| |
| await session.page.click(selector) |
|
|
| session.last_used = datetime.now() |
|
|
| |
| if wait_for: |
| try: |
| await session.page.wait_for_selector(wait_for, timeout=5000) |
| except Exception as e: |
| logger.debug(f"Wait for selector '{wait_for}' not found or timeout: {e}") |
| |
|
|
| logger.info(f"Clicked {selector} in session {session_id}") |
|
|
| return { |
| "success": True, |
| "session_id": session_id, |
| "selector": selector |
| } |
|
|
| except Exception as e: |
| logger.error(f"Click failed for session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_extract_text( |
| session_id: str, |
| selector: Optional[str] = None, |
| user_id: str = None |
| ) -> Dict[str, Any]: |
| """ |
| Extract text content from the page or specific elements. |
| |
| Args: |
| session_id: Browser session ID |
| selector: Optional CSS selector (if None, extracts full page text) |
| user_id: User ID for validation |
| |
| Returns: |
| Dict with extracted text |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| if selector: |
| |
| elements = await session.page.query_selector_all(selector) |
| texts = [await el.inner_text() for el in elements] |
| result_text = "\n".join(texts) |
| else: |
| |
| result_text = await session.page.inner_text("body") |
|
|
| session.last_used = datetime.now() |
|
|
| logger.info(f"Extracted {len(result_text)} chars from session {session_id}") |
|
|
| return { |
| "success": True, |
| "session_id": session_id, |
| "text": result_text, |
| "length": len(result_text) |
| } |
|
|
| except Exception as e: |
| logger.error(f"Text extraction failed for session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_execute_script( |
| session_id: str, |
| script: str, |
| user_id: str = None |
| ) -> Dict[str, Any]: |
| """ |
| Execute JavaScript in the browser context. |
| |
| Args: |
| session_id: Browser session ID |
| script: JavaScript code to execute |
| user_id: User ID for validation |
| |
| Returns: |
| Dict with execution result |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| |
| result = await session.page.evaluate(script) |
|
|
| session.last_used = datetime.now() |
|
|
| logger.info(f"Executed script in session {session_id}") |
|
|
| return { |
| "success": True, |
| "session_id": session_id, |
| "result": result |
| } |
|
|
| except Exception as e: |
| logger.error(f"Script execution failed for session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_close_session( |
| session_id: str, |
| user_id: str = None |
| ) -> Dict[str, Any]: |
| """ |
| Close a browser session. |
| |
| Args: |
| session_id: Browser session ID |
| user_id: User ID for validation |
| |
| Returns: |
| Dict with close result |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| success = await get_browser_manager().close_session(session_id) |
|
|
| if success: |
| logger.info(f"Closed browser session {session_id}") |
| return { |
| "success": True, |
| "session_id": session_id |
| } |
| else: |
| return { |
| "success": False, |
| "error": "Failed to close session" |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to close session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|
|
|
| async def browser_get_page_info( |
| session_id: str, |
| user_id: str = None |
| ) -> Dict[str, Any]: |
| """ |
| Get information about the current page. |
| |
| Args: |
| session_id: Browser session ID |
| user_id: User ID for validation |
| |
| Returns: |
| Dict with page information |
| """ |
| session = get_browser_manager().get_session(session_id) |
| if not session: |
| return { |
| "success": False, |
| "error": f"Browser session {session_id} not found" |
| } |
|
|
| if user_id and session.user_id != user_id: |
| return { |
| "success": False, |
| "error": "Session belongs to different user" |
| } |
|
|
| try: |
| |
| title = await session.page.title() |
| url = session.page.url |
|
|
| |
| cookies = await session.context.cookies() |
|
|
| logger.info(f"Retrieved page info for session {session_id}") |
|
|
| return { |
| "success": True, |
| "session_id": session_id, |
| "title": title, |
| "url": url, |
| "cookies_count": len(cookies) |
| } |
|
|
| except Exception as e: |
| logger.error(f"Failed to get page info for session {session_id}: {e}") |
| return { |
| "success": False, |
| "error": str(e) |
| } |
|
|