"""Playwright browser automation executor. Executes deterministic, scenario-based browser automation tasks. Each scenario is a reusable workflow registered in the scenario registry. """ import time import uuid from pathlib import Path from typing import Any from playwright.async_api import async_playwright, Route, Request from app.config import get_settings from app.executors.base import BaseExecutor, ExecutionResult from app.playwright_scenarios import get_scenario, get_scenario_names, ScenarioOptions from app.utils.validation import is_domain_allowed, validate_allowed_domains, ValidationError class PlaywrightExecutor(BaseExecutor): """Browser automation executor using Playwright scenarios. Executes pre-defined browser automation scenarios with strict security controls. Each execution creates an isolated browser context with domain restrictions. Payload: { "scenario": "nextcloud_initial_setup", # Required: registered scenario name "inputs": { # Required: scenario-specific inputs "base_url": "https://cloud.example.com", "admin_username": "admin", "admin_password": "secret123" }, "options": { # Optional configuration "timeout_ms": 60000, # Action timeout (default: 60000) "screenshot_on_failure": true, # Screenshot on fail (default: true) "screenshot_on_success": false, # Screenshot on success (default: false) "save_trace": false, # Save trace file (default: false) "allowed_domains": ["cloud.example.com"] # REQUIRED: domain allowlist } } Security: - allowed_domains is REQUIRED - blocks all requests to non-listed domains - Browser runs in headless mode only (not configurable) - Each execution gets an isolated browser context - Artifacts are stored in per-task directories """ @property def task_type(self) -> str: return "PLAYWRIGHT" async def execute(self, payload: dict[str, Any]) -> ExecutionResult: """Execute a Playwright scenario. Args: payload: Task payload with scenario, inputs, and options Returns: ExecutionResult with scenario output and artifact paths """ start_time = time.time() settings = get_settings() try: # Validate required fields self.validate_payload(payload, ["scenario", "inputs"]) scenario_name = payload["scenario"] inputs = payload["inputs"] options_dict = payload.get("options", {}) # Validate allowed_domains is present allowed_domains = options_dict.get("allowed_domains") if not allowed_domains: return ExecutionResult( success=False, data={"scenario": scenario_name}, error="Security error: 'allowed_domains' is required in options", duration_ms=(time.time() - start_time) * 1000, ) # Validate domain patterns try: allowed_domains = validate_allowed_domains(allowed_domains) except ValidationError as e: return ExecutionResult( success=False, data={"scenario": scenario_name}, error=f"Invalid allowed_domains: {e}", duration_ms=(time.time() - start_time) * 1000, ) # Get scenario from registry scenario = get_scenario(scenario_name) if scenario is None: available = get_scenario_names() return ExecutionResult( success=False, data={ "scenario": scenario_name, "available_scenarios": available, }, error=f"Unknown scenario: '{scenario_name}'. Available: {available}", duration_ms=(time.time() - start_time) * 1000, ) # Validate scenario inputs missing_inputs = scenario.validate_inputs(inputs) if missing_inputs: return ExecutionResult( success=False, data={ "scenario": scenario_name, "missing_inputs": missing_inputs, "required_inputs": scenario.required_inputs, }, error=f"Missing required inputs: {missing_inputs}", duration_ms=(time.time() - start_time) * 1000, ) # Create artifacts directory for this execution task_id = str(uuid.uuid4())[:8] artifacts_dir = Path(settings.playwright_artifacts_dir) / f"task-{task_id}" artifacts_dir.mkdir(parents=True, exist_ok=True) # Build scenario options scenario_options = ScenarioOptions( timeout_ms=options_dict.get("timeout_ms", settings.playwright_default_timeout_ms), screenshot_on_failure=options_dict.get("screenshot_on_failure", True), screenshot_on_success=options_dict.get("screenshot_on_success", False), save_trace=options_dict.get("save_trace", False), allowed_domains=allowed_domains, artifacts_dir=artifacts_dir, ) self.logger.info( "playwright_scenario_starting", scenario=scenario_name, task_id=task_id, allowed_domains=allowed_domains, ) # Execute scenario with browser result = await self._run_scenario( scenario=scenario, inputs=inputs, options=scenario_options, task_id=task_id, ) duration_ms = (time.time() - start_time) * 1000 self.logger.info( "playwright_scenario_completed", scenario=scenario_name, success=result.success, duration_ms=duration_ms, ) return ExecutionResult( success=result.success, data={ "scenario": scenario_name, "result": result.data, "screenshots": result.screenshots, "artifacts_dir": str(artifacts_dir), "trace_path": result.trace_path, }, error=result.error, duration_ms=duration_ms, ) except ValueError as e: # Validation errors return ExecutionResult( success=False, data={}, error=str(e), duration_ms=(time.time() - start_time) * 1000, ) except Exception as e: self.logger.error( "playwright_executor_error", error=str(e), error_type=type(e).__name__, ) return ExecutionResult( success=False, data={}, error=f"Playwright executor error: {e}", duration_ms=(time.time() - start_time) * 1000, ) async def _run_scenario( self, scenario, inputs: dict[str, Any], options: ScenarioOptions, task_id: str, ): """Run a scenario with browser and domain restrictions. Args: scenario: The scenario instance to execute inputs: Scenario inputs options: Scenario options task_id: Task identifier for logging Returns: ScenarioResult from the scenario execution """ from app.playwright_scenarios import ScenarioResult settings = get_settings() blocked_requests: list[str] = [] async def route_handler(route: Route, request: Request) -> None: """Block requests to non-allowed domains.""" url = request.url if is_domain_allowed(url, options.allowed_domains): await route.continue_() else: blocked_requests.append(url) self.logger.warning( "playwright_blocked_request", url=url, task_id=task_id, ) await route.abort("blockedbyclient") async with async_playwright() as p: # Launch browser in headless mode (always) browser = await p.chromium.launch( headless=True, args=[ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", ], ) try: # Create isolated context context = await browser.new_context( viewport={"width": 1280, "height": 720}, user_agent="LetsBe-SysAdmin-Agent/1.0 Playwright", ) # Set default timeouts context.set_default_timeout(options.timeout_ms) context.set_default_navigation_timeout( settings.playwright_navigation_timeout_ms ) # Start tracing if enabled if options.save_trace and options.artifacts_dir: await context.tracing.start( screenshots=True, snapshots=True, ) # Apply domain restrictions via route interception await context.route("**/*", route_handler) # Create page page = await context.new_page() try: # Run scenario setup hook await scenario.setup(page, options) # Execute the scenario result = await scenario.execute(page, inputs, options) # Take success screenshot if enabled if options.screenshot_on_success and options.artifacts_dir: screenshot_path = options.artifacts_dir / "success.png" await page.screenshot(path=str(screenshot_path)) result.screenshots.append(str(screenshot_path)) except Exception as e: # Capture failure screenshot screenshots = [] if options.screenshot_on_failure and options.artifacts_dir: try: screenshot_path = options.artifacts_dir / "failure.png" await page.screenshot(path=str(screenshot_path)) screenshots.append(str(screenshot_path)) except Exception as screenshot_error: self.logger.warning( "playwright_screenshot_failed", error=str(screenshot_error), ) result = ScenarioResult( success=False, data={"blocked_requests": blocked_requests}, screenshots=screenshots, error=str(e), ) finally: # Run scenario teardown hook try: await scenario.teardown(page, options) except Exception as teardown_error: self.logger.warning( "playwright_teardown_error", error=str(teardown_error), ) # Stop tracing and save if options.save_trace and options.artifacts_dir: trace_path = options.artifacts_dir / "trace.zip" await context.tracing.stop(path=str(trace_path)) result.trace_path = str(trace_path) # Add blocked requests info if blocked_requests: result.data["blocked_requests"] = blocked_requests return result finally: await browser.close()