diff --git a/.gitignore b/.gitignore index 81d4580..99fdcba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ __pycache__/ # Local config files *.local.* +# Video recordings +recordings/* + # C extensions *.so diff --git a/agents/browser-use-muzzle/Dockerfile b/agents/browser-use-muzzle/Dockerfile new file mode 100644 index 0000000..16b2695 --- /dev/null +++ b/agents/browser-use-muzzle/Dockerfile @@ -0,0 +1,39 @@ +# Use an official Python runtime as a parent image +FROM python:3.12-slim + +# Install system dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + chromium \ + fonts-unifont \ + fonts-liberation \ + fonts-dejavu-core \ + fonts-freefont-ttf \ + fonts-noto-core \ + ffmpeg \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && ln -s /usr/bin/chromium /usr/bin/chromium-browser + +# Install the zoo certificate +RUN curl -o /usr/local/share/ca-certificates/zoo-root.crt \ + https://raw.githubusercontent.com/bgrins/the_zoo/refs/heads/main/core/caddy/root.crt \ + && update-ca-certificates + +# Set environment variables for Chromium +ENV CHROME_PATH=/usr/bin/chromium-browser + +RUN pip install --no-cache-dir "browser-use[video]==0.7.10" + +# Create a working directory +WORKDIR /app + +# Copy worker script into the container +COPY main.py . + +# Create directory for recordings +RUN mkdir -p /app/tmp/recordings + +# Keep container running (users will docker exec to run tasks) +CMD ["tail", "-f", "/dev/null"] diff --git a/agents/browser-use-muzzle/README.md b/agents/browser-use-muzzle/README.md new file mode 100644 index 0000000..75282c1 --- /dev/null +++ b/agents/browser-use-muzzle/README.md @@ -0,0 +1,57 @@ +# Browser Use Muzzle Agent + +A containerized browser automation worker using [browser-use](https://github.com/browser-use/browser-use) for AI-powered web tasks. + +## Setup + +### Start the container +```bash +docker-compose up -d browser-use +``` + +This starts a persistent container with browser-use installed and ready to execute tasks. + +## Usage + +Execute browser automation tasks by running the worker script inside the container: + +### Basic task +```bash +docker-compose exec browser-use python main.py \ + --task "Go to example.zoo and summarize the contents" +``` + +### With starting URL +```bash +docker-compose exec browser-use python main.py \ + --task "Summarize this page" \ + --starting-url "https://status.zoo" +``` + +### Custom max steps +```bash +docker-compose exec browser-use python main.py \ + --task "Navigate to example.zoo, find the contact page, and extract email addresses" \ + --max-steps 15 +``` + +## Environment Variables + +Set these in the `.env` file or manually in your environment + +- `OPENAI_API_KEY` (required): Your OpenAI API key +- `OPENAI_MODEL` (optional): OpenAI model to use (default: `gpt-4o-mini`) + +## Features + +- **Proxy support**: Requires proxy at `host.docker.internal:3128` (fails if unavailable) +- **Domain restrictions**: Only allows navigation to `*.zoo` domains +- **Video recordings**: Saved to `./recordings/` directory +- **SSL bypass**: Ignores certificate errors for self-signed certs + +## Architecture + +- Container runs continuously with `tail -f /dev/null` +- Each task execution is isolated (separate `docker exec` invocation) +- No shared state between task runs +- Worker script exits after each task completion \ No newline at end of file diff --git a/agents/browser-use-muzzle/main.py b/agents/browser-use-muzzle/main.py new file mode 100644 index 0000000..c542585 --- /dev/null +++ b/agents/browser-use-muzzle/main.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Browser-use worker - executes a single browser automation task and exits. +Usage: python browser_use_worker.py --task "Your task here" [--starting-url URL] [--max-steps N] +""" + +import argparse +import asyncio +import os +import socket +import sys +from pathlib import Path +from typing import Optional + +from browser_use import Agent, Browser, ChatOpenAI +from browser_use.browser import ProxySettings + + +def check_proxy_available(host: str, port: int) -> bool: + """Check if proxy is available before attempting to use it.""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + result = sock.connect_ex((host, port)) + sock.close() + return result == 0 + except Exception as e: + print(f"Error checking proxy: {e}", file=sys.stderr) + return False + + +async def run_task(task: str, max_steps: int = 10, starting_url: Optional[str] = None): + """Run a browser-use agent task with proxy.""" + proxy_host = 'host.docker.internal' + proxy_port = 3128 + + # Verify proxy is available + if not check_proxy_available(proxy_host, proxy_port): + raise RuntimeError(f"Proxy not available at {proxy_host}:{proxy_port}") + + print(f"Proxy available at {proxy_host}:{proxy_port}") + + # Configure browser session + browser_session = Browser( + record_video_dir=Path('./tmp/recordings'), + proxy=ProxySettings(server=f'http://{proxy_host}:{proxy_port}'), + args=['--ignore-certificate-errors'] + ) + + # Build initial actions if starting URL is provided + initial_actions = None + if starting_url: + initial_actions = [{'go_to_url': {'url': starting_url}}] + print(f"Starting at URL: {starting_url}") + + # Create and run agent + agent = Agent( + task=task, + llm=ChatOpenAI( + model=os.environ.get('OPENAI_MODEL', 'gpt-4o-mini'), + api_key=os.environ.get('OPENAI_API_KEY') + ), + browser_session=browser_session, + allowed_domains=['*.zoo'], + initial_actions=initial_actions, + ) + + print(f"Running task: {task}") + result = await agent.run(max_steps=max_steps) + + print(f"\nTask completed successfully") + print(f"Result: {result}") + + return result + + +def main(): + parser = argparse.ArgumentParser( + description='Execute a browser automation task using browser-use' + ) + parser.add_argument( + '--task', + required=True, + help='Task description for the browser agent' + ) + parser.add_argument( + '--starting-url', + help='Optional starting URL for the task' + ) + parser.add_argument( + '--max-steps', + type=int, + default=10, + help='Maximum number of steps for the agent (default: 10)' + ) + + args = parser.parse_args() + + # Validate required environment variables + if not os.environ.get('OPENAI_API_KEY'): + print("Error: OPENAI_API_KEY environment variable not set", file=sys.stderr) + sys.exit(1) + + # Run the task + try: + asyncio.run(run_task(args.task, args.max_steps, args.starting_url)) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c48ac46 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + browser-use: + build: ./agents/browser-use-muzzle + image: browser-use-muzzle + container_name: browser-use-muzzle + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini} + volumes: + - ./recordings:/app/tmp/recordings + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped diff --git a/muzzle/experiment.py b/muzzle/experiment.py index d45c5bb..9f9f9a3 100644 --- a/muzzle/experiment.py +++ b/muzzle/experiment.py @@ -13,11 +13,21 @@ import os class Experiment: - def __init__(self, task_config_path, agent_config_path, browser_config_path, injection_config_path): + def __init__(self, task_config_path, agent_config_path, browser_config_path, injection_config_path, agent_name=None): self.id = str(uuid.uuid4()) - self.browser_config, self.agent_config, self.task_config = get_configs( - browser_config_path, agent_config_path, task_config_path, injection_config_path - ) + self.agent_name = agent_name + + # Only load configs if not using a built-in agent + if agent_name: + # For built-in agents like browser_use, only load task config + self.task_config = get_configs(None, None, task_config_path, injection_config_path)[2] + self.browser_config = None + self.agent_config = None + else: + self.browser_config, self.agent_config, self.task_config = get_configs( + browser_config_path, agent_config_path, task_config_path, injection_config_path + ) + self.logger = logging.getLogger(self.__class__.__name__) self.docker_client = docker.from_env() @@ -133,6 +143,86 @@ def _evaluate_task_completion(self): evaluator = Evaluator() evaluator.evaluate(self.task_config) + # Map of agent names to docker-compose service names + AGENT_SERVICE_MAP = { + "browser_use": "browser-use", + } + + def _run_named_agent_worker(self, agent_name): + """ + Run a named agent worker via docker-compose exec. + + Args: + agent_name (str): Name of the agent (e.g., 'browser_use') + """ + self.logger.info(f"Running {agent_name} worker...") + + # Get service name from map + service_name = self.AGENT_SERVICE_MAP.get(agent_name) + if not service_name: + raise ValueError(f"Unknown agent name: {agent_name}. Supported agents: {list(self.AGENT_SERVICE_MAP.keys())}") + + import subprocess + + # Check if container is running, if not start it + self.logger.info(f"Checking if {service_name} container is running...") + check_cmd = ["docker-compose", "ps", "-q", service_name] + result = subprocess.run(check_cmd, capture_output=True, text=True) + + if not result.stdout.strip(): + self.logger.info(f"Container {service_name} is not running. Starting it now...") + start_cmd = ["docker-compose", "up", "-d", service_name] + start_result = subprocess.run(start_cmd, capture_output=True, text=True) + if start_result.returncode != 0: + raise RuntimeError(f"Failed to start {service_name} container: {start_result.stderr}") + self.logger.info(f"Container {service_name} started successfully") + else: + self.logger.info(f"Container {service_name} is already running") + + # Build the docker-compose exec command + # Format task with credentials and starting URL like the old agent does + parameters = self.task_config.get("parameters", {}) + + base_task = self.task_config.get("task", "NO TASK PROVIDED") + username = parameters.get("gitlab_username", "user") + password = parameters.get("gitlab_password", "pass") + default_url = self.task_config.get("default_url", "about:blank") + max_steps = self.task_config.get("max_steps", 10) + + self.logger.info(f"Extracted - username: {username}, password: {password}, default_url: {default_url}") + + task_description = f"Your username is {username} and your password is {password}. FIRST GO TO {default_url}. {base_task}" + + cmd = [ + "docker-compose", "exec", "-T", service_name, + "python", "main.py", + "--task", task_description, + "--max-steps", str(max_steps), + "--starting-url", default_url + ] + + self.logger.info(f"Running command: {' '.join(cmd)}") + + # Execute the command and stream output + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + # Stream output in real-time + for line in process.stdout: + self.logger.info(line.rstrip()) + + # Wait for completion + return_code = process.wait() + if return_code != 0: + raise RuntimeError(f"{agent_name} worker failed with exit code {return_code}") + + self.logger.info(f"{agent_name} worker completed successfully") + def run(self): """ Run the experiment. @@ -145,8 +235,11 @@ def run(self): # Step 2: Seed the Zoo with the desired content for the experiment self._seed_zoo() - # Step 3: Run the docker container with the desired experiment parameters - self._run_docker_container() + # Step 3: Run the agent (either docker container or named agent worker) + if self.agent_name: + self._run_named_agent_worker(self.agent_name) + else: + self._run_docker_container() # Step 4: Evaluate the task completion self._evaluate_task_completion() \ No newline at end of file diff --git a/muzzle/orchestrator.py b/muzzle/orchestrator.py index 30ed1f0..f709386 100644 --- a/muzzle/orchestrator.py +++ b/muzzle/orchestrator.py @@ -6,7 +6,7 @@ def __init__(self): self._queue = [] self.logger = logging.getLogger(self.__class__.__name__) - def add(self, task_config_path, agent_config_path, browser_config_path, injection_config_path): + def add(self, task_config_path, agent_config_path, browser_config_path, injection_config_path, agent_name=None): """ Add a new experiment to the queue. Args: @@ -14,30 +14,33 @@ def add(self, task_config_path, agent_config_path, browser_config_path, injectio agent_config_path (str): Path to the agent configuration JSON file. browser_config_path (str): Path to the browser configuration JSON file. injection_config_path (str): Path to the injection configuration JSON file. + agent_name (str, optional): Built-in agent name (e.g., 'browser_use') """ e = Experiment( task_config_path=task_config_path, agent_config_path=agent_config_path, browser_config_path=browser_config_path, - injection_config_path=injection_config_path + injection_config_path=injection_config_path, + agent_name=agent_name ) self.logger.info(f"Adding experiment: {e.id}") self._queue.append(e) - def addAll(self, task_configs_folder_path, agent_config_path, browser_config_path, injection_config_path): + def addAll(self, task_configs_folder_path, agent_config_path, browser_config_path, injection_config_path, agent_name=None): """ Add all experiments from a folder to the queue. Args: task_configs_folder_path (str): Path to the folder containing task configuration JSON files. agent_config_path (str): Path to the agent configuration JSON file. browser_config_path (str): Path to the browser configuration JSON file. + agent_name (str, optional): Built-in agent name (e.g., 'browser_use') """ import os task_folder = [f for f in os.listdir(task_configs_folder_path) if f.endswith(".json")] self.logger.info(f"Adding all ({len(task_folder)}) experiments from folder: {task_configs_folder_path}") for filename in task_folder: task_config_path = os.path.join(task_configs_folder_path, filename) - self.add(task_config_path, agent_config_path, browser_config_path, injection_config_path) + self.add(task_config_path, agent_config_path, browser_config_path, injection_config_path, agent_name) def runAll(self): """ diff --git a/muzzle/utils/configs.py b/muzzle/utils/configs.py index 0ca5dbd..5bb8fc8 100644 --- a/muzzle/utils/configs.py +++ b/muzzle/utils/configs.py @@ -7,15 +7,15 @@ def get_configs(browser_config_path, inference_config_path, task_config_path, injection_config_path): """ - Load configurations from JSON files. + Load configurations from JSON files. Args: - browser_config_path (str): Path to the browser configuration JSON file. - inference_config_path (str): Path to the inference configuration JSON file. + browser_config_path (str or None): Path to the browser configuration JSON file. + inference_config_path (str or None): Path to the inference configuration JSON file. task_config_path (str): Path to the task configuration JSON file. injection_config_path (str): Path to the injection configuration JSON file. """ - browser_config = json.load(open(browser_config_path)) - inference_config = json.load(open(inference_config_path)) + browser_config = json.load(open(browser_config_path)) if browser_config_path else None + inference_config = json.load(open(inference_config_path)) if inference_config_path else None injection_config = json.load(open(injection_config_path)) task_config = json.load(open(task_config_path)) # Merge injection config into task config diff --git a/run.py b/run.py index 4c32e12..58073ad 100644 --- a/run.py +++ b/run.py @@ -20,11 +20,16 @@ def main(): parser.add_argument("--run-all", action="store_true", default=False, help="Run all experiments.") parser.add_argument("--task-config", type=str, required=True, help="Path to the task configuration JSON file. If --run-all is set, this should be a folder containing multiple task configuration JSON files.") parser.add_argument("--injection-config", type=str, required=True, help="Path to the injection configuration JSON file.") - parser.add_argument("--agent-config", type=str, required=True, help="Path to the agent configuration JSON file.") - parser.add_argument("--browser-config", type=str, required=True, help="Path to the browser configuration JSON file.") + parser.add_argument("--agent", type=str, help="Built-in agent name (e.g., 'browser_use'). If specified, --agent-config and --browser-config are optional.") + parser.add_argument("--agent-config", type=str, help="Path to the agent configuration JSON file.") + parser.add_argument("--browser-config", type=str, help="Path to the browser configuration JSON file.") parser.add_argument("--refresh-images", action="store_true", default=False, help="Remove existing Docker images before building new ones.") args = parser.parse_args() + # Validate that either --agent or both --agent-config and --browser-config are provided + if not args.agent and (not args.agent_config or not args.browser_config): + parser.error("Either --agent must be specified, or both --agent-config and --browser-config must be provided.") + if not args.zoo_path and not zoo_api.get_zoo_path(): parser.error("Either --zoo-path must be provided or a Zoo path must be set in ~/.muzzle/zoo.yml") @@ -46,13 +51,15 @@ def main(): orchestrator.addAll(task_configs_folder_path=args.task_config, agent_config_path=args.agent_config, browser_config_path=args.browser_config, - injection_config_path=args.injection_config) + injection_config_path=args.injection_config, + agent_name=args.agent) else: # Add a single experiment orchestrator.add(task_config_path=args.task_config, agent_config_path=args.agent_config, browser_config_path=args.browser_config, - injection_config_path=args.injection_config) + injection_config_path=args.injection_config, + agent_name=args.agent) # Run all experiments in the queue orchestrator.runAll()