| """ |
| Browser Automation Routes |
| |
| API endpoints for browser automation with CDP via Playwright. |
| |
| 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. |
| """ |
|
|
| from datetime import datetime |
| from typing import Any, Dict, Optional |
| import uuid |
| from fastapi import Depends, HTTPException |
| from pydantic import BaseModel |
| from sqlalchemy.orm import Session |
|
|
| from core.agent_context_resolver import AgentContextResolver |
| from core.base_routes import BaseAPIRouter |
| from core.database import get_db |
| from core.error_handler_decorator import handle_errors |
| from core.error_handlers import ErrorCode |
| from core.feature_flags import FeatureFlags |
| from core.models import AgentExecution, BrowserAudit, BrowserSession, User |
| from core.security_dependencies import get_current_user |
| from core.service_factory import ServiceFactory |
| from core.structured_logger import get_logger |
| from tools.browser_tool import ( |
| browser_click, |
| browser_close_session, |
| browser_create_session, |
| browser_execute_script, |
| browser_extract_text, |
| browser_fill_form, |
| browser_get_page_info, |
| browser_navigate, |
| browser_screenshot, |
| ) |
|
|
| logger = get_logger(__name__) |
|
|
| router = BaseAPIRouter(prefix="/api/browser", tags=["browser"]) |
|
|
|
|
| |
| |
| |
|
|
| class CreateSessionRequest(BaseModel): |
| headless: Optional[bool] = None |
| browser_type: str = "chromium" |
| agent_id: Optional[str] = None |
|
|
|
|
| class NavigateRequest(BaseModel): |
| session_id: str |
| url: str |
| wait_until: str = "load" |
| agent_id: Optional[str] = None |
|
|
|
|
| class ScreenshotRequest(BaseModel): |
| session_id: str |
| full_page: bool = False |
| path: Optional[str] = None |
| agent_id: Optional[str] = None |
|
|
|
|
| class FillFormRequest(BaseModel): |
| session_id: str |
| selectors: Dict[str, str] |
| submit: bool = False |
| agent_id: Optional[str] = None |
|
|
|
|
| class ClickRequest(BaseModel): |
| session_id: str |
| selector: str |
| wait_for: Optional[str] = None |
| agent_id: Optional[str] = None |
|
|
|
|
| class ExtractTextRequest(BaseModel): |
| session_id: str |
| selector: Optional[str] = None |
| agent_id: Optional[str] = None |
|
|
|
|
| class ExecuteScriptRequest(BaseModel): |
| session_id: str |
| script: str |
| agent_id: Optional[str] = None |
|
|
|
|
| class CloseSessionRequest(BaseModel): |
| session_id: str |
| agent_id: Optional[str] = None |
|
|
|
|
| |
| |
| |
|
|
| async def _check_browser_governance( |
| db: Session, |
| agent_id: str, |
| user_id: str, |
| action_type: str |
| ) -> tuple: |
| """ |
| Perform governance check for browser actions. |
| |
| Returns: |
| Tuple of (agent, governance_check_result) |
| agent can be None if no agent_id or agent not found |
| governance_check_result can be None if check skipped |
| """ |
| agent = None |
| governance_check = None |
|
|
| if agent_id and FeatureFlags.should_enforce_governance('browser'): |
| try: |
| 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=action_type |
| ) |
|
|
| if agent: |
| governance_check = governance.can_perform_action( |
| agent_id=agent.id, |
| action_type=action_type |
| ) |
|
|
| if not governance_check["allowed"]: |
| logger.warning( |
| f"Governance blocked: agent={agent.id}, action={action_type}, " |
| f"reason={governance_check['reason']}" |
| ) |
| raise router.governance_denied_error( |
| agent_id=agent.id, |
| action=action_type, |
| maturity_level=agent.maturity_level if hasattr(agent, 'maturity_level') else agent.status, |
| required_level="INTERN", |
| reason=governance_check['reason'] |
| ) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Governance check failed: {e}") |
|
|
| return agent, governance_check |
|
|
| def _create_browser_audit( |
| db: Session, |
| user_id: str, |
| session_id: str, |
| action_type: str, |
| action_target: Optional[str], |
| action_params: Dict[str, Any], |
| success: bool, |
| result_summary: Optional[str] = None, |
| error_message: Optional[str] = None, |
| result_data: Optional[Dict[str, Any]] = None, |
| duration_ms: Optional[int] = None, |
| agent_id: Optional[str] = None, |
| agent_execution_id: Optional[str] = None, |
| governance_check_passed: Optional[bool] = None, |
| ) -> BrowserAudit: |
| """Create a browser audit entry.""" |
| try: |
| audit = BrowserAudit( |
| id=str(uuid.uuid4()), |
| workspace_id="default", |
| agent_id=agent_id, |
| agent_execution_id=agent_execution_id, |
| user_id=user_id, |
| session_id=session_id, |
| action_type=action_type, |
| action_target=action_target, |
| action_params=action_params, |
| success=success, |
| result_summary=result_summary, |
| error_message=error_message, |
| result_data=result_data or {}, |
| duration_ms=duration_ms, |
| governance_check_passed=governance_check_passed |
| ) |
| db.add(audit) |
| db.commit() |
| db.refresh(audit) |
| return audit |
| except Exception as e: |
| logger.error(f"Failed to create browser audit: {e}") |
| return None |
|
|
|
|
| |
| |
| |
|
|
| @router.post("/session/create") |
| @handle_errors(error_code=ErrorCode.INTERNAL_SERVER_ERROR) |
| async def create_browser_session( |
| request: CreateSessionRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """ |
| Create a new browser session. |
| |
| Requires INTERN+ maturity level for agent-initiated sessions. |
| """ |
| result = await browser_create_session( |
| user_id=current_user.id, |
| agent_id=request.agent_id, |
| headless=request.headless, |
| browser_type=request.browser_type, |
| db=db |
| ) |
|
|
| if not result.get("success"): |
| error_msg = result.get("error", "Failed to create browser session") |
| if "governance" in error_msg.lower() or "permission" in error_msg.lower(): |
| raise router.permission_denied_error("create_browser_session", "BrowserSession", details={"error": error_msg}) |
| raise router.error_response("SESSION_CREATE_FAILED", error_msg, status_code=400) |
|
|
| |
| try: |
| db_session = BrowserSession( |
| session_id=result["session_id"], |
| workspace_id="default", |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| browser_type=request.browser_type, |
| headless=result.get("headless", True), |
| status="active", |
| metadata_json={"created_via": "api"} |
| ) |
| db.add(db_session) |
| db.commit() |
| db.refresh(db_session) |
|
|
| result["db_session_id"] = db_session.id |
| except Exception as e: |
| logger.error( |
| "Failed to create browser session record", |
| session_id=result.get("session_id"), |
| error=str(e) |
| ) |
|
|
| return result |
|
|
|
|
| @router.post("/navigate") |
| @handle_errors(error_code=ErrorCode.INTERNAL_SERVER_ERROR) |
| async def navigate( |
| request: NavigateRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Navigate to a URL in an existing browser session.""" |
| start_time = datetime.now() |
| agent = None |
| governance_check = None |
|
|
| |
| if request.agent_id and FeatureFlags.should_enforce_governance('browser'): |
| try: |
| resolver = AgentContextResolver(db) |
| governance = ServiceFactory.get_governance_service(db) |
|
|
| agent, _ = await resolver.resolve_agent_for_request( |
| user_id=current_user.id, |
| requested_agent_id=request.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"]: |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="navigate", |
| action_target=request.url, |
| action_params={"wait_until": request.wait_until}, |
| success=False, |
| error_message=f"Governance blocked: {governance_check['reason']}", |
| agent_id=agent.id, |
| governance_check_passed=False |
| ) |
|
|
| raise router.governance_denied_error( |
| agent_id=agent.id, |
| action="browser_navigate", |
| maturity_level=agent.maturity_level if hasattr(agent, 'maturity_level') else agent.status, |
| required_level="INTERN", |
| reason=governance_check['reason'] |
| ) |
|
|
| |
| execution = AgentExecution( |
| agent_id=agent.id, |
| workspace_id="default", |
| status="running", |
| input_summary=f"Navigate to {request.url}", |
| triggered_by="browser_api" |
| ) |
| db.add(execution) |
| db.commit() |
| db.refresh(execution) |
|
|
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"Governance check failed: {e}") |
|
|
| |
| result = await browser_navigate( |
| session_id=request.session_id, |
| url=request.url, |
| wait_until=request.wait_until, |
| user_id=current_user.id |
| ) |
|
|
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) |
|
|
| |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="navigate", |
| action_target=request.url, |
| action_params={"wait_until": request.wait_until}, |
| success=result.get("success", False), |
| result_summary=result.get("title"), |
| error_message=result.get("error"), |
| result_data=result if result.get("success") else None, |
| duration_ms=duration_ms, |
| agent_id=agent.id if agent else None, |
| governance_check_passed=governance_check["allowed"] if governance_check else None |
| ) |
|
|
| |
| if result.get("success"): |
| try: |
| db_session = db.query(BrowserSession).filter( |
| BrowserSession.session_id == request.session_id |
| ).first() |
| if db_session: |
| db_session.current_url = result.get("url") |
| db_session.page_title = result.get("title") |
| db.commit() |
| except Exception as e: |
| logger.error(f"Failed to update browser session: {e}") |
|
|
| return result |
|
|
|
|
| @router.post("/screenshot") |
| async def screenshot( |
| request: ScreenshotRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Take a screenshot of the current page. Requires INTERN+ maturity for agent-initiated actions.""" |
| start_time = datetime.now() |
|
|
| |
| agent = None |
| governance_check = None |
|
|
| if request.agent_id: |
| agent, governance_check = await _check_browser_governance( |
| db=db, |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| action_type="browser_screenshot" |
| ) |
|
|
| result = await browser_screenshot( |
| session_id=request.session_id, |
| full_page=request.full_page, |
| path=request.path, |
| user_id=current_user.id |
| ) |
|
|
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) |
|
|
| |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="screenshot", |
| action_target=request.path or "base64", |
| action_params={"full_page": request.full_page}, |
| success=result.get("success", False), |
| result_summary=f"Screenshot ({result.get('size_bytes')} bytes)", |
| error_message=result.get("error"), |
| result_data=result if result.get("success") else None, |
| duration_ms=duration_ms, |
| agent_id=agent.id if agent else None, |
| governance_check_passed=governance_check["allowed"] if governance_check else None |
| ) |
|
|
| return result |
|
|
|
|
| @router.post("/fill-form") |
| async def fill_form( |
| request: FillFormRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Fill form fields using CSS selectors. Requires SUPERVISED+ maturity for agent-initiated form submissions.""" |
| start_time = datetime.now() |
|
|
| |
| agent = None |
| governance_check = None |
|
|
| if request.agent_id: |
| |
| if request.submit: |
| agent, governance_check = await _check_browser_governance( |
| db=db, |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| action_type="browser_form_submit" |
| ) |
| else: |
| agent, governance_check = await _check_browser_governance( |
| db=db, |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| action_type="browser_fill_form" |
| ) |
|
|
| result = await browser_fill_form( |
| session_id=request.session_id, |
| selectors=request.selectors, |
| submit=request.submit, |
| user_id=current_user.id |
| ) |
|
|
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) |
|
|
| |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="fill_form", |
| action_target=f"{len(request.selectors)} fields", |
| action_params={"selectors": request.selectors, "submit": request.submit}, |
| success=result.get("success", False), |
| result_summary=f"Filled {result.get('fields_filled', 0)} fields", |
| error_message=result.get("error"), |
| result_data=result if result.get("success") else None, |
| duration_ms=duration_ms, |
| agent_id=agent.id if agent else None, |
| governance_check_passed=governance_check["allowed"] if governance_check else None |
| ) |
|
|
| return result |
|
|
|
|
| @router.post("/click") |
| async def click( |
| request: ClickRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Click an element using CSS selector. Requires INTERN+ maturity for agent-initiated actions.""" |
| start_time = datetime.now() |
|
|
| |
| agent = None |
| governance_check = None |
|
|
| if request.agent_id: |
| agent, governance_check = await _check_browser_governance( |
| db=db, |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| action_type="browser_click" |
| ) |
|
|
| result = await browser_click( |
| session_id=request.session_id, |
| selector=request.selector, |
| wait_for=request.wait_for, |
| user_id=current_user.id |
| ) |
|
|
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) |
|
|
| |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="click", |
| action_target=request.selector, |
| action_params={"wait_for": request.wait_for}, |
| success=result.get("success", False), |
| error_message=result.get("error"), |
| result_data=result if result.get("success") else None, |
| duration_ms=duration_ms, |
| agent_id=agent.id if agent else None, |
| governance_check_passed=governance_check["allowed"] if governance_check else None |
| ) |
|
|
| return result |
|
|
|
|
| @router.post("/extract-text") |
| async def extract_text( |
| request: ExtractTextRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Extract text content from the page or specific elements. Requires INTERN+ maturity for agent-initiated actions.""" |
| start_time = datetime.now() |
|
|
| |
| agent = None |
| governance_check = None |
|
|
| if request.agent_id: |
| agent, governance_check = await _check_browser_governance( |
| db=db, |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| action_type="browser_extract_text" |
| ) |
|
|
| result = await browser_extract_text( |
| session_id=request.session_id, |
| selector=request.selector, |
| user_id=current_user.id |
| ) |
|
|
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) |
|
|
| |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="extract_text", |
| action_target=request.selector or "full_page", |
| action_params={"selector": request.selector}, |
| success=result.get("success", False), |
| result_summary=f"Extracted {result.get('length', 0)} chars", |
| error_message=result.get("error"), |
| result_data={"length": result.get("length")} if result.get("success") else None, |
| duration_ms=duration_ms, |
| agent_id=agent.id if agent else None, |
| governance_check_passed=governance_check["allowed"] if governance_check else None |
| ) |
|
|
| return result |
|
|
|
|
| @router.post("/execute-script") |
| async def execute_script( |
| request: ExecuteScriptRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Execute JavaScript in the browser context. Requires SUPERVISED+ maturity for agent-initiated script execution.""" |
| start_time = datetime.now() |
|
|
| |
| agent = None |
| governance_check = None |
|
|
| if request.agent_id: |
| agent, governance_check = await _check_browser_governance( |
| db=db, |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| action_type="browser_execute_script" |
| ) |
|
|
| result = await browser_execute_script( |
| session_id=request.session_id, |
| script=request.script, |
| user_id=current_user.id |
| ) |
|
|
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) |
|
|
| |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="execute_script", |
| action_target=f"{len(request.script)} chars", |
| action_params={"script_length": len(request.script)}, |
| success=result.get("success", False), |
| result_summary="Script executed", |
| error_message=result.get("error"), |
| duration_ms=duration_ms, |
| agent_id=agent.id if agent else None, |
| governance_check_passed=governance_check["allowed"] if governance_check else None |
| ) |
|
|
| return result |
|
|
|
|
| @router.post("/session/close") |
| async def close_session( |
| request: CloseSessionRequest, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Close a browser session. Requires INTERN+ maturity for agent-initiated session closure.""" |
| start_time = datetime.now() |
|
|
| |
| agent = None |
| governance_check = None |
|
|
| if request.agent_id: |
| agent, governance_check = await _check_browser_governance( |
| db=db, |
| agent_id=request.agent_id, |
| user_id=current_user.id, |
| action_type="browser_close_session" |
| ) |
|
|
| result = await browser_close_session( |
| session_id=request.session_id, |
| user_id=current_user.id |
| ) |
|
|
| duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) |
|
|
| |
| _create_browser_audit( |
| db=db, |
| user_id=current_user.id, |
| session_id=request.session_id, |
| action_type="close_session", |
| action_target=None, |
| action_params={}, |
| success=result.get("success", False), |
| result_summary="Session closed", |
| error_message=result.get("error"), |
| duration_ms=duration_ms, |
| agent_id=agent.id if agent else None, |
| governance_check_passed=governance_check["allowed"] if governance_check else None |
| ) |
|
|
| |
| if result.get("success"): |
| try: |
| db_session = db.query(BrowserSession).filter( |
| BrowserSession.session_id == request.session_id |
| ).first() |
| if db_session: |
| db_session.status = "closed" |
| db_session.closed_at = datetime.now() |
| db.commit() |
| except Exception as e: |
| logger.error(f"Failed to update browser session: {e}") |
|
|
| return result |
|
|
|
|
| @router.get("/session/{session_id}/info") |
| async def get_session_info( |
| session_id: str, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Get information about a browser session.""" |
| result = await browser_get_page_info( |
| session_id=session_id, |
| user_id=current_user.id |
| ) |
|
|
| |
| try: |
| db_session = db.query(BrowserSession).filter( |
| BrowserSession.session_id == session_id |
| ).first() |
|
|
| if db_session: |
| result["db_session_id"] = db_session.id |
| result["created_at"] = db_session.created_at.isoformat() |
| result["status"] = db_session.status |
| result["browser_type"] = db_session.browser_type |
| except Exception as e: |
| logger.error(f"Failed to fetch session info: {e}") |
|
|
| return result |
|
|
|
|
| @router.get("/sessions") |
| async def list_sessions( |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """List all browser sessions for the current user.""" |
| try: |
| sessions = db.query(BrowserSession).filter( |
| BrowserSession.user_id == current_user.id |
| ).order_by(BrowserSession.created_at.desc()).limit(50).all() |
|
|
| return router.success_response( |
| data=[ |
| { |
| "session_id": s.session_id, |
| "id": s.id, |
| "browser_type": s.browser_type, |
| "headless": s.headless, |
| "status": s.status, |
| "current_url": s.current_url, |
| "page_title": s.page_title, |
| "created_at": s.created_at.isoformat(), |
| "closed_at": s.closed_at.isoformat() if s.closed_at else None |
| } |
| for s in sessions |
| ], |
| message=f"Retrieved {len(sessions)} sessions" |
| ) |
| except Exception as e: |
| logger.error(f"Failed to list sessions: {e}") |
| raise router.internal_error(f"Failed to list sessions: {str(e)}") |
|
|
|
|
| @router.get("/audit") |
| async def get_browser_audit( |
| session_id: Optional[str] = None, |
| limit: int = 100, |
| current_user: User = Depends(get_current_user), |
| db: Session = Depends(get_db) |
| ): |
| """Get browser audit log for the current user.""" |
| try: |
| query = db.query(BrowserAudit).filter( |
| BrowserAudit.user_id == current_user.id |
| ) |
|
|
| if session_id: |
| query = query.filter(BrowserAudit.session_id == session_id) |
|
|
| audits = query.order_by(BrowserAudit.created_at.desc()).limit(limit).all() |
|
|
| return router.success_response( |
| data=[ |
| { |
| "id": a.id, |
| "session_id": a.session_id, |
| "action_type": a.action_type, |
| "action_target": a.action_target, |
| "success": a.success, |
| "result_summary": a.result_summary, |
| "error_message": a.error_message, |
| "duration_ms": a.duration_ms, |
| "created_at": a.created_at.isoformat() |
| } |
| for a in audits |
| ], |
| message=f"Retrieved {len(audits)} audit entries" |
| ) |
| except Exception as e: |
| logger.error(f"Failed to fetch audit log: {e}") |
| raise router.internal_error(f"Failed to fetch audit log: {str(e)}") |
|
|