Spaces:
Sleeping
Sleeping
| """ | |
| 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__) | |
| # Feature flags | |
| 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() | |
| # Select browser type | |
| 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: # chromium (default) | |
| self.browser = await self.playwright.chromium.launch(headless=self.headless) | |
| # Create context with realistic viewport | |
| 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" | |
| ) | |
| # Create default page | |
| 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) | |
| # Global session manager | |
| _session_manager = BrowserSessionManager() | |
| def get_browser_manager() -> BrowserSessionManager: | |
| """Get the global browser session manager.""" | |
| return _session_manager | |
| # ============================================================================ | |
| # Browser Tool Functions | |
| # ============================================================================ | |
| 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: | |
| # Governance: Check agent permissions (browser_navigate = INTERN+) | |
| 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']}" | |
| } | |
| # Create execution record | |
| 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) | |
| # Create session | |
| 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 | |
| ) | |
| # Record outcome | |
| 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: | |
| # Navigate to URL | |
| response = await session.page.goto(url, wait_until=wait_until, timeout=30000) | |
| session.last_used = datetime.now() | |
| # Get page info | |
| 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: | |
| # Take screenshot | |
| screenshot_bytes = await session.page.screenshot( | |
| full_page=full_page, | |
| type="png" | |
| ) | |
| session.last_used = datetime.now() | |
| # Encode to base64 | |
| screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8") | |
| # Save to file if path provided | |
| 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: | |
| # Wait for element | |
| await session.page.wait_for_selector(selector, timeout=5000) | |
| # Check input type and fill accordingly | |
| 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 | |
| } | |
| # Submit form if requested | |
| if submit: | |
| # Try to find submit button or form | |
| try: | |
| # Look for button with type="submit" | |
| 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: | |
| # Try form submission | |
| 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: | |
| # Wait for element to be clickable | |
| await session.page.wait_for_selector(selector, state="visible", timeout=5000) | |
| # Click element | |
| await session.page.click(selector) | |
| session.last_used = datetime.now() | |
| # Wait for navigation or element if specified | |
| 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}") | |
| # Continue anyway - don't fail the entire operation | |
| 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: | |
| # Extract text from specific element(s) | |
| elements = await session.page.query_selector_all(selector) | |
| texts = [await el.inner_text() for el in elements] | |
| result_text = "\n".join(texts) | |
| else: | |
| # Extract full page text | |
| 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: | |
| # Execute script | |
| 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: | |
| # Get page info | |
| title = await session.page.title() | |
| url = session.page.url | |
| # Get cookies | |
| 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) | |
| } | |