diff --git a/docs/content/docs/computer-sdk/computers.mdx b/docs/content/docs/computer-sdk/computers.mdx index 470e8a65..336f687f 100644 --- a/docs/content/docs/computer-sdk/computers.mdx +++ b/docs/content/docs/computer-sdk/computers.mdx @@ -45,34 +45,90 @@ This is a cloud container running the Computer Server. This is the easiest & saf ## cua local containers -cua provides local containers. This can be done using either the Lume CLI (macOS) or Docker CLI (Linux, Windows). +cua provides local containers using different providers depending on your host operating system: -### Lume (macOS Only): + + + + 1. Install lume cli -1. Install lume cli + ```bash + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" + ``` -```bash -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" -``` + 2. Start a local cua container -2. Start a local cua container + ```bash + lume run macos-sequoia-cua:latest + ``` -```bash -lume run macos-sequoia-cua:latest -``` + 3. Connect with Computer -3. Connect with Computer - - - ```python + from computer import Computer + computer = Computer( os_type="macos", provider_type="lume", name="macos-sequoia-cua:latest" ) - await computer.run() # Connect to the container + await computer.run() # Launch & connect to the container + ``` + + + + + 1. Enable Windows Sandbox (requires Windows 10 Pro/Enterprise or Windows 11) + 2. Install pywinsandbox dependency + + ```bash + pip install -U git+git://github.com/karkason/pywinsandbox.git + ``` + + 3. Windows Sandbox will be automatically configured when you run the CLI + + ```python + from computer import Computer + + computer = Computer( + os_type="windows", + provider_type="winsandbox", + ephemeral=True # Windows Sandbox is always ephemeral + ) + + await computer.run() # Launch & connect to Windows Sandbox + ``` + + + + + 1. Install Docker Desktop or Docker Engine + + 2. Build or pull the CUA Ubuntu container + + ```bash + # Option 1: Pull from Docker Hub + docker pull trycua/cua-ubuntu:latest + + # Option 2: Build locally + cd libs/kasm + docker build -t cua-ubuntu:latest . + ``` + + 3. Connect with Computer + + ```python + from computer import Computer + + computer = Computer( + os_type="linux", + provider_type="docker", + image="trycua/cua-ubuntu:latest", + name="my-cua-container" + ) + + await computer.run() # Launch & connect to Docker container ``` @@ -93,8 +149,8 @@ Connect with: ```python - computer = Computer(use_host_computer_server=True) await - computer.run() # Connect to the host desktop + computer = Computer(use_host_computer_server=True) + await computer.run() # Connect to the host desktop ``` diff --git a/docs/content/docs/quickstart-cli.mdx b/docs/content/docs/quickstart-cli.mdx index ac11c726..dd385c62 100644 --- a/docs/content/docs/quickstart-cli.mdx +++ b/docs/content/docs/quickstart-cli.mdx @@ -21,12 +21,62 @@ cua combines Computer (interface) + Agent (AI) for automating desktop apps. The -## Create Your First cua Container +## Set Up Your Computer Environment -1. Go to [trycua.com/signin](https://www.trycua.com/signin) -2. Navigate to **Dashboard > Containers > Create Instance** -3. Create a **Medium, Ubuntu 22** container -4. Note your container name and API key +Choose how you want to run your cua computer. **Cloud containers are recommended** for the easiest setup: + + + + + **Easiest & safest way to get started** + + 1. Go to [trycua.com/signin](https://www.trycua.com/signin) + 2. Navigate to **Dashboard > Containers > Create Instance** + 3. Create a **Medium, Ubuntu 22** container + 4. Note your container name and API key + + Your cloud container will be automatically configured and ready to use. + + + + + 1. Install lume cli + + ```bash + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" + ``` + + 2. Start a local cua container + + ```bash + lume run macos-sequoia-cua:latest + ``` + + + + + 1. Enable Windows Sandbox (requires Windows 10 Pro/Enterprise or Windows 11) + 2. Install pywinsandbox dependency + + ```bash + pip install -U git+git://github.com/karkason/pywinsandbox.git + ``` + + 3. Windows Sandbox will be automatically configured when you run the CLI + + + + + 1. Install Docker Desktop or Docker Engine + + 2. Pull the CUA Ubuntu container + + ```bash + docker pull trycua/cua-ubuntu:latest + ``` + + + diff --git a/docs/content/docs/quickstart-devs.mdx b/docs/content/docs/quickstart-devs.mdx index cd002746..1ba86e68 100644 --- a/docs/content/docs/quickstart-devs.mdx +++ b/docs/content/docs/quickstart-devs.mdx @@ -20,12 +20,62 @@ cua combines Computer (interface) + Agent (AI) for automating desktop apps. Comp -## Create Your First cua Container +## Set Up Your Computer Environment -1. Go to [trycua.com/signin](https://www.trycua.com/signin) -2. Navigate to **Dashboard > Containers > Create Instance** -3. Create a **Medium, Ubuntu 22** container -4. Note your container name and API key +Choose how you want to run your cua computer. **Cloud containers are recommended** for the easiest setup: + + + + + **Easiest & safest way to get started** + + 1. Go to [trycua.com/signin](https://www.trycua.com/signin) + 2. Navigate to **Dashboard > Containers > Create Instance** + 3. Create a **Medium, Ubuntu 22** container + 4. Note your container name and API key + + Your cloud container will be automatically configured and ready to use. + + + + + 1. Install lume cli + + ```bash + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" + ``` + + 2. Start a local cua container + + ```bash + lume run macos-sequoia-cua:latest + ``` + + + + + 1. Enable Windows Sandbox (requires Windows 10 Pro/Enterprise or Windows 11) + 2. Install pywinsandbox dependency + + ```bash + pip install -U git+git://github.com/karkason/pywinsandbox.git + ``` + + 3. Windows Sandbox will be automatically configured when you run the CLI + + + + + 1. Install Docker Desktop or Docker Engine + + 2. Pull the CUA Ubuntu container + + ```bash + docker pull trycua/cua-ubuntu:latest + ``` + + + @@ -35,9 +85,15 @@ cua combines Computer (interface) + Agent (AI) for automating desktop apps. Comp - ```bash pip install "cua-agent[all]" cua-computer ``` + ```bash + pip install "cua-agent[all]" cua-computer + ``` + + + ```bash + npm install @trycua/computer + ``` - ```bash npm install @trycua/computer ``` diff --git a/docs/content/docs/quickstart-ui.mdx b/docs/content/docs/quickstart-ui.mdx index fb7a2b0d..ad6ea396 100644 --- a/docs/content/docs/quickstart-ui.mdx +++ b/docs/content/docs/quickstart-ui.mdx @@ -21,12 +21,62 @@ cua combines Computer (interface) + Agent (AI) for automating desktop apps. The -## Create Your First cua Container +## Set Up Your Computer Environment -1. Go to [trycua.com/signin](https://www.trycua.com/signin) -2. Navigate to **Dashboard > Containers > Create Instance** -3. Create a **Medium, Ubuntu 22** container -4. Note your container name and API key +Choose how you want to run your cua computer. **Cloud containers are recommended** for the easiest setup: + + + + + **Easiest & safest way to get started** + + 1. Go to [trycua.com/signin](https://www.trycua.com/signin) + 2. Navigate to **Dashboard > Containers > Create Instance** + 3. Create a **Medium, Ubuntu 22** container + 4. Note your container name and API key + + Your cloud container will be automatically configured and ready to use. + + + + + 1. Install lume cli + + ```bash + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)" + ``` + + 2. Start a local cua container + + ```bash + lume run macos-sequoia-cua:latest + ``` + + + + + 1. Enable Windows Sandbox (requires Windows 10 Pro/Enterprise or Windows 11) + 2. Install pywinsandbox dependency + + ```bash + pip install -U git+git://github.com/karkason/pywinsandbox.git + ``` + + 3. Windows Sandbox will be automatically configured when you run the CLI + + + + + 1. Install Docker Desktop or Docker Engine + + 2. Pull the CUA Ubuntu container + + ```bash + docker pull trycua/cua-ubuntu:latest + ``` + + + diff --git a/examples/docker_examples.py b/examples/docker_examples.py new file mode 100644 index 00000000..42dcf3ac --- /dev/null +++ b/examples/docker_examples.py @@ -0,0 +1,43 @@ +import asyncio +from computer.providers.factory import VMProviderFactory +from computer import Computer, VMProviderType +import os + +async def main(): + # # Create docker provider + # provider = VMProviderFactory.create_provider( + # provider_type="docker", + # image="cua-ubuntu:latest", # Your CUA Ubuntu image + # port=8080, + # vnc_port=6901 + # ) + + # # Run a container + # async with provider: + # vm_info = await provider.run_vm( + # image="cua-ubuntu:latest", + # name="my-cua-container", + # run_opts={ + # "memory": "4GB", + # "cpu": 2, + # "vnc_port": 6901, + # "api_port": 8080 + # } + # ) + # print(vm_info) + + computer = Computer( + os_type="linux", + provider_type=VMProviderType.DOCKER, + name="my-cua-container", + image="cua-ubuntu:latest", + ) + + await computer.run() + + screenshot = await computer.interface.screenshot() + with open("screenshot_docker.png", "wb") as f: + f.write(screenshot) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/libs/kasm/Dockerfile b/libs/kasm/Dockerfile new file mode 100644 index 00000000..c3a4c795 --- /dev/null +++ b/libs/kasm/Dockerfile @@ -0,0 +1,37 @@ +FROM kasmweb/core-ubuntu-jammy:1.17.0 +USER root + +ENV HOME=/home/kasm-default-profile +ENV STARTUPDIR=/dockerstartup +ENV INST_SCRIPTS=$STARTUPDIR/install +WORKDIR $HOME + +######### Customize Container Here ########### + +# Installing python, pip, and libraries +RUN apt-get update +RUN apt install -y wget build-essential libncursesw5-dev libssl-dev \ +libsqlite3-dev tk-dev libgdbm-dev libc6-dev libbz2-dev libffi-dev zlib1g-dev +RUN add-apt-repository ppa:deadsnakes/ppa +RUN apt install -y python3.11 python3-pip python3-tk python3-dev \ +gnome-screenshot wmctrl ffmpeg socat xclip + +RUN pip install cua-computer-server + +# Disable SSL requirement +RUN sed -i 's/require_ssl: true/require_ssl: false/g' /usr/share/kasmvnc/kasmvnc_defaults.yaml +RUN sed -i 's/-sslOnly//g' /dockerstartup/vnc_startup.sh + +# Running the python script on startup +RUN echo "/usr/bin/python3 -m computer_server" > $STARTUPDIR/custom_startup.sh \ +&& chmod +x $STARTUPDIR/custom_startup.sh + +######### End Customizations ########### + +RUN chown 1000:0 $HOME +RUN $STARTUPDIR/set_user_permission.sh $HOME +ENV HOME=/home/kasm-user +WORKDIR $HOME +RUN mkdir -p $HOME && chown -R 1000:0 $HOME + +USER 1000 \ No newline at end of file diff --git a/libs/kasm/README.md b/libs/kasm/README.md new file mode 100644 index 00000000..dc91fbe1 --- /dev/null +++ b/libs/kasm/README.md @@ -0,0 +1,121 @@ +# CUA Ubuntu Container + +Containerized virtual desktop for Computer-Using Agents (CUA). Utilizes Kasm's MIT-licensed Ubuntu XFCE container as a base with computer-server pre-installed. + +## Features + +- Ubuntu 22.04 (Jammy) with XFCE desktop environment +- Pre-installed computer-server for remote computer control +- VNC access for visual desktop interaction +- Python 3.11 with necessary libraries +- Screen capture tools (gnome-screenshot, wmctrl, ffmpeg) +- Clipboard utilities (xclip, socat) + +## Usage + +### Building the Container + +```bash +docker build -t cua-ubuntu:latest . +``` + +### Pushing to Registry + +To push the container to a Docker registry: + +```bash +# Tag for your registry (replace with your registry URL) +docker tag cua-ubuntu:latest your-registry.com/cua-ubuntu:latest + +# Push to registry +docker push your-registry.com/cua-ubuntu:latest +``` + +For Docker Hub: + +```bash +# Tag for Docker Hub (replace 'trycua' with your Docker Hub username) +docker tag cua-ubuntu:latest trycua/cua-ubuntu:latest + +# Login to Docker Hub +docker login + +# Push to Docker Hub +docker push trycua/cua-ubuntu:latest +``` + +### Running the Container Manually + +```bash +docker run --rm -it --shm-size=512m -p 6901:6901 -p 8000:8000 -e VNC_PW=password cua-ubuntu:latest +``` + +- **VNC Access**: Available at `http://localhost:6901` (username: `kasm-user`, password: `password`) +- **Computer Server API**: Available at `http://localhost:8000` + +### Using with CUA Docker Provider + +This container is designed to work with the CUA Docker provider for automated container management: + +```python +from computer.providers.factory import VMProviderFactory + +# Create docker provider +provider = VMProviderFactory.create_provider( + provider_type="docker", + image="cua-ubuntu:latest", + port=8000, # computer-server API port + noVNC_port=6901 # VNC port +) + +# Run a container +async with provider: + vm_info = await provider.run_vm( + image="cua-ubuntu:latest", + name="my-cua-container", + run_opts={ + "memory": "4GB", + "cpu": 2, + "vnc_port": 6901, + "api_port": 8000 + } + ) +``` + +## Container Configuration + +### Ports +- **6901**: VNC web interface (noVNC) +- **8080**: Computer-server API endpoint + +### Environment Variables +- `VNC_PW`: VNC password (default: "password") +- `DISPLAY`: X11 display (set to ":0") + +### Volumes +- `/home/kasm-user/storage`: Persistent storage mount point +- `/home/kasm-user/shared`: Shared folder mount point + +## Creating Snapshots + +You can create a snapshot of the container at any time: + +```bash +docker commit cua-ubuntu-snapshot:latest +``` + +Then run the snapshot: + +```bash +docker run --rm -it --shm-size=512m -p 6901:6901 -p 8080:8080 -e VNC_PW=password cua-ubuntu-snapshot:latest +``` + +## Integration with CUA System + +This container integrates seamlessly with the CUA computer provider system: + +- **Automatic Management**: Use the Docker provider for lifecycle management +- **Resource Control**: Configure memory, CPU, and storage limits +- **Network Access**: Automatic port mapping and IP detection +- **Storage Persistence**: Mount host directories for persistent data +- **Monitoring**: Real-time container status and health checking diff --git a/libs/python/agent/agent/cli.py b/libs/python/agent/agent/cli.py index 4d17ca15..de9e3450 100644 --- a/libs/python/agent/agent/cli.py +++ b/libs/python/agent/agent/cli.py @@ -94,7 +94,7 @@ def print_action(action_type: str, details: Dict[str, Any], total_cost: float): # Format action details args_str = "" if action_type == "click" and "x" in details and "y" in details: - args_str = f"_{details['button']}({details['x']}, {details['y']})" + args_str = f"_{details.get('button', 'left')}({details['x']}, {details['y']})" elif action_type == "type" and "text" in details: text = details["text"] if len(text) > 50: diff --git a/libs/python/agent/agent/ui/gradio/app.py b/libs/python/agent/agent/ui/gradio/app.py index be04d931..b7112f04 100644 --- a/libs/python/agent/agent/ui/gradio/app.py +++ b/libs/python/agent/agent/ui/gradio/app.py @@ -39,6 +39,7 @@ global_agent = None global_computer = None SETTINGS_FILE = Path(".gradio_settings.json") +logging.basicConfig(level=logging.INFO) import dotenv if dotenv.load_dotenv(): diff --git a/libs/python/agent/agent/ui/gradio/ui_components.py b/libs/python/agent/agent/ui/gradio/ui_components.py index c601fb6c..bc6978f7 100644 --- a/libs/python/agent/agent/ui/gradio/ui_components.py +++ b/libs/python/agent/agent/ui/gradio/ui_components.py @@ -187,7 +187,7 @@ if __name__ == "__main__": """
CUA Logo + src="https://github.com/trycua/cua/blob/main/img/logo_white.png?raw=true" />
""" ) @@ -201,22 +201,33 @@ if __name__ == "__main__": ) with gr.Accordion("Computer Configuration", open=True): - computer_os = gr.Radio( - choices=["macos", "linux", "windows"], - label="Operating System", - value="macos", - info="Select the operating system for the computer", - ) - is_windows = platform.system().lower() == "windows" is_mac = platform.system().lower() == "darwin" - providers = ["cloud", "localhost"] + providers = ["cloud", "localhost", "docker"] if is_mac: providers += ["lume"] if is_windows: providers += ["winsandbox"] + # Remove unavailable options + # MacOS is unavailable if Lume is not available + # Windows is unavailable if Winsandbox is not available + # Linux is always available + # This should be removed once we support macOS and Windows on the cloud provider + computer_choices = ["macos", "linux", "windows"] + if not is_mac or "lume" not in providers: + computer_choices.remove("macos") + if not is_windows or "winsandbox" not in providers: + computer_choices.remove("windows") + + computer_os = gr.Radio( + choices=computer_choices, + label="Operating System", + value=computer_choices[0], + info="Select the operating system for the computer", + ) + computer_provider = gr.Radio( choices=providers, label="Provider", diff --git a/libs/python/computer/computer/computer.py b/libs/python/computer/computer/computer.py index 854a6cce..59b074c8 100644 --- a/libs/python/computer/computer/computer.py +++ b/libs/python/computer/computer/computer.py @@ -43,7 +43,7 @@ class Computer: cpu: str = "4", os_type: OSType = "macos", name: str = "", - image: str = "macos-sequoia-cua:latest", + image: Optional[str] = None, shared_directories: Optional[List[str]] = None, use_host_computer_server: bool = False, verbosity: Union[int, LogLevel] = logging.INFO, @@ -88,6 +88,12 @@ class Computer: self.logger = Logger("computer", verbosity) self.logger.info("Initializing Computer...") + if os_type == "macos": + image = "macos-sequoia-cua:latest" + elif os_type == "linux": + image = "trycua/cua-ubuntu:latest" + image = str(image) + # Store original parameters self.image = image self.port = port @@ -301,6 +307,19 @@ class Computer: storage=storage, verbose=verbose, ephemeral=ephemeral, + noVNC_port=noVNC_port, + ) + elif self.provider_type == VMProviderType.DOCKER: + self.config.vm_provider = VMProviderFactory.create_provider( + self.provider_type, + port=port, + host=host, + storage=storage, + shared_path=shared_path, + image=image or "trycua/cua-ubuntu:latest", + verbose=verbose, + ephemeral=ephemeral, + noVNC_port=noVNC_port, ) else: raise ValueError(f"Unsupported provider type: {self.provider_type}") diff --git a/libs/python/computer/computer/providers/base.py b/libs/python/computer/computer/providers/base.py index a3540e0e..23526097 100644 --- a/libs/python/computer/computer/providers/base.py +++ b/libs/python/computer/computer/providers/base.py @@ -11,6 +11,7 @@ class VMProviderType(StrEnum): LUMIER = "lumier" CLOUD = "cloud" WINSANDBOX = "winsandbox" + DOCKER = "docker" UNKNOWN = "unknown" diff --git a/libs/python/computer/computer/providers/docker/__init__.py b/libs/python/computer/computer/providers/docker/__init__.py new file mode 100644 index 00000000..048f526c --- /dev/null +++ b/libs/python/computer/computer/providers/docker/__init__.py @@ -0,0 +1,13 @@ +"""Docker provider for running containers with computer-server.""" + +from .provider import DockerProvider + +# Check if Docker is available +try: + import subprocess + subprocess.run(["docker", "--version"], capture_output=True, check=True) + HAS_DOCKER = True +except (subprocess.SubprocessError, FileNotFoundError): + HAS_DOCKER = False + +__all__ = ["DockerProvider", "HAS_DOCKER"] diff --git a/libs/python/computer/computer/providers/docker/provider.py b/libs/python/computer/computer/providers/docker/provider.py new file mode 100644 index 00000000..82ad411c --- /dev/null +++ b/libs/python/computer/computer/providers/docker/provider.py @@ -0,0 +1,502 @@ +""" +Docker VM provider implementation. + +This provider uses Docker containers running the CUA Ubuntu image to create +Linux VMs with computer-server. It handles VM lifecycle operations through Docker +commands and container management. +""" + +import logging +import json +import asyncio +from typing import Dict, List, Optional, Any +import subprocess +import time +import re + +from ..base import BaseVMProvider, VMProviderType + +# Setup logging +logger = logging.getLogger(__name__) + +# Check if Docker is available +try: + subprocess.run(["docker", "--version"], capture_output=True, check=True) + HAS_DOCKER = True +except (subprocess.SubprocessError, FileNotFoundError): + HAS_DOCKER = False + + +class DockerProvider(BaseVMProvider): + """ + Docker VM Provider implementation using Docker containers. + + This provider uses Docker to run containers with the CUA Ubuntu image + that includes computer-server for remote computer use. + """ + + def __init__( + self, + port: Optional[int] = 8000, + host: str = "localhost", + storage: Optional[str] = None, + shared_path: Optional[str] = None, + image: str = "trycua/cua-ubuntu:latest", + verbose: bool = False, + ephemeral: bool = False, + vnc_port: Optional[int] = 6901, + ): + """Initialize the Docker VM Provider. + + Args: + port: Currently unused (VM provider port) + host: Hostname for the API server (default: localhost) + storage: Path for persistent VM storage + shared_path: Path for shared folder between host and container + image: Docker image to use (default: "trycua/cua-ubuntu:latest") + verbose: Enable verbose logging + ephemeral: Use ephemeral (temporary) storage + vnc_port: Port for VNC interface (default: 6901) + """ + self.host = host + self.api_port = 8000 + self.vnc_port = vnc_port + self.ephemeral = ephemeral + + # Handle ephemeral storage (temporary directory) + if ephemeral: + self.storage = "ephemeral" + else: + self.storage = storage + + self.shared_path = shared_path + self.image = image + self.verbose = verbose + self._container_id = None + self._running_containers = {} # Track running containers by name + + @property + def provider_type(self) -> VMProviderType: + """Return the provider type.""" + return VMProviderType.DOCKER + + def _parse_memory(self, memory_str: str) -> str: + """Parse memory string to Docker format. + + Examples: + "8GB" -> "8g" + "1024MB" -> "1024m" + "512" -> "512m" + """ + if isinstance(memory_str, int): + return f"{memory_str}m" + + if isinstance(memory_str, str): + # Extract number and unit + match = re.match(r"(\d+)([A-Za-z]*)", memory_str) + if match: + value, unit = match.groups() + unit = unit.upper() + + if unit == "GB" or unit == "G": + return f"{value}g" + elif unit == "MB" or unit == "M" or unit == "": + return f"{value}m" + + # Default fallback + logger.warning(f"Could not parse memory string '{memory_str}', using 4g default") + return "4g" # Default to 4GB + + async def get_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]: + """Get VM information by name. + + Args: + name: Name of the VM to get information for + storage: Optional storage path override. If provided, this will be used + instead of the provider's default storage path. + + Returns: + Dictionary with VM information including status, IP address, etc. + """ + try: + # Check if container exists and get its status + cmd = ["docker", "inspect", name] + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + # Container doesn't exist + return { + "name": name, + "status": "not_found", + "ip_address": None, + "ports": {}, + "image": self.image, + "provider": "docker" + } + + # Parse container info + container_info = json.loads(result.stdout)[0] + state = container_info["State"] + network_settings = container_info["NetworkSettings"] + + # Determine status + if state["Running"]: + status = "running" + elif state["Paused"]: + status = "paused" + else: + status = "stopped" + + # Get IP address + ip_address = network_settings.get("IPAddress", "") + if not ip_address and "Networks" in network_settings: + # Try to get IP from bridge network + for network_name, network_info in network_settings["Networks"].items(): + if network_info.get("IPAddress"): + ip_address = network_info["IPAddress"] + break + + # Get port mappings + ports = {} + if "Ports" in network_settings and network_settings["Ports"]: + # network_settings["Ports"] is a dict like: + # {'6901/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '6901'}, ...], ...} + for container_port, port_mappings in network_settings["Ports"].items(): + if port_mappings: # Check if there are any port mappings + # Take the first mapping (usually the IPv4 one) + for mapping in port_mappings: + if mapping.get("HostPort"): + ports[container_port] = mapping["HostPort"] + break # Use the first valid mapping + + return { + "name": name, + "status": status, + "ip_address": ip_address or "127.0.0.1", # Use localhost if no IP + "ports": ports, + "image": container_info["Config"]["Image"], + "provider": "docker", + "container_id": container_info["Id"][:12], # Short ID + "created": container_info["Created"], + "started": state.get("StartedAt", ""), + } + + except Exception as e: + logger.error(f"Error getting VM info for {name}: {e}") + import traceback + traceback.print_exc() + return { + "name": name, + "status": "error", + "error": str(e), + "provider": "docker" + } + + async def list_vms(self) -> List[Dict[str, Any]]: + """List all Docker containers managed by this provider.""" + try: + # List all containers (running and stopped) with the CUA image + cmd = ["docker", "ps", "-a", "--filter", f"ancestor={self.image}", "--format", "json"] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + containers = [] + if result.stdout.strip(): + for line in result.stdout.strip().split('\n'): + if line.strip(): + container_data = json.loads(line) + vm_info = await self.get_vm(container_data["Names"]) + containers.append(vm_info) + + return containers + + except subprocess.CalledProcessError as e: + logger.error(f"Error listing containers: {e.stderr}") + return [] + except Exception as e: + logger.error(f"Error listing VMs: {e}") + import traceback + traceback.print_exc() + return [] + + async def run_vm(self, image: str, name: str, run_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]: + """Run a VM with the given options. + + Args: + image: Name/tag of the Docker image to use + name: Name of the container to run + run_opts: Options for running the VM, including: + - memory: Memory limit (e.g., "4GB", "2048MB") + - cpu: CPU limit (e.g., 2 for 2 cores) + - vnc_port: Specific port for VNC interface + - api_port: Specific port for computer-server API + + Returns: + Dictionary with VM status information + """ + try: + # Check if container already exists + existing_vm = await self.get_vm(name, storage) + if existing_vm["status"] == "running": + logger.info(f"Container {name} is already running") + return existing_vm + elif existing_vm["status"] in ["stopped", "paused"]: + # Start existing container + logger.info(f"Starting existing container {name}") + start_cmd = ["docker", "start", name] + result = subprocess.run(start_cmd, capture_output=True, text=True, check=True) + + # Wait for container to be ready + await self._wait_for_container_ready(name) + return await self.get_vm(name, storage) + + # Use provided image or default + docker_image = image if image != "default" else self.image + + # Build docker run command + cmd = ["docker", "run", "-d", "--name", name] + + # Add memory limit if specified + if "memory" in run_opts: + memory_limit = self._parse_memory(run_opts["memory"]) + cmd.extend(["--memory", memory_limit]) + + # Add CPU limit if specified + if "cpu" in run_opts: + cpu_count = str(run_opts["cpu"]) + cmd.extend(["--cpus", cpu_count]) + + # Add port mappings + vnc_port = run_opts.get("vnc_port", self.vnc_port) + api_port = run_opts.get("api_port", self.api_port) + + if vnc_port: + cmd.extend(["-p", f"{vnc_port}:6901"]) # VNC port + if api_port: + cmd.extend(["-p", f"{api_port}:8000"]) # computer-server API port + + # Add volume mounts if storage is specified + storage_path = storage or self.storage + if storage_path and storage_path != "ephemeral": + # Mount storage directory + cmd.extend(["-v", f"{storage_path}:/home/kasm-user/storage"]) + + # Add shared path if specified + if self.shared_path: + cmd.extend(["-v", f"{self.shared_path}:/home/kasm-user/shared"]) + + # Add environment variables + cmd.extend(["-e", "VNC_PW=password"]) # Set VNC password + cmd.extend(["-e", "VNCOPTIONS=-disableBasicAuth"]) # Disable VNC basic auth + + # Add the image + cmd.append(docker_image) + + logger.info(f"Running Docker container with command: {' '.join(cmd)}") + + # Run the container + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + container_id = result.stdout.strip() + + logger.info(f"Container {name} started with ID: {container_id[:12]}") + + # Store container info + self._container_id = container_id + self._running_containers[name] = container_id + + # Wait for container to be ready + await self._wait_for_container_ready(name) + + # Return VM info + vm_info = await self.get_vm(name, storage) + vm_info["container_id"] = container_id[:12] + + return vm_info + + except subprocess.CalledProcessError as e: + error_msg = f"Failed to run container {name}: {e.stderr}" + logger.error(error_msg) + return { + "name": name, + "status": "error", + "error": error_msg, + "provider": "docker" + } + except Exception as e: + error_msg = f"Error running VM {name}: {e}" + logger.error(error_msg) + return { + "name": name, + "status": "error", + "error": error_msg, + "provider": "docker" + } + + async def _wait_for_container_ready(self, container_name: str, timeout: int = 60) -> bool: + """Wait for the Docker container to be fully ready. + + Args: + container_name: Name of the Docker container to check + timeout: Maximum time to wait in seconds (default: 60 seconds) + + Returns: + True if the container is running and ready + """ + logger.info(f"Waiting for container {container_name} to be ready...") + + start_time = time.time() + while time.time() - start_time < timeout: + try: + # Check if container is running + vm_info = await self.get_vm(container_name) + if vm_info["status"] == "running": + logger.info(f"Container {container_name} is running") + + # Additional check: try to connect to computer-server API + # This is optional - we'll just wait a bit more for services to start + await asyncio.sleep(5) + return True + + except Exception as e: + logger.debug(f"Container {container_name} not ready yet: {e}") + + await asyncio.sleep(2) + + logger.warning(f"Container {container_name} did not become ready within {timeout} seconds") + return False + + async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]: + """Stop a running VM by stopping the Docker container.""" + try: + logger.info(f"Stopping container {name}") + + # Stop the container + cmd = ["docker", "stop", name] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + # Remove from running containers tracking + if name in self._running_containers: + del self._running_containers[name] + + logger.info(f"Container {name} stopped successfully") + + return { + "name": name, + "status": "stopped", + "message": "Container stopped successfully", + "provider": "docker" + } + + except subprocess.CalledProcessError as e: + error_msg = f"Failed to stop container {name}: {e.stderr}" + logger.error(error_msg) + return { + "name": name, + "status": "error", + "error": error_msg, + "provider": "docker" + } + except Exception as e: + error_msg = f"Error stopping VM {name}: {e}" + logger.error(error_msg) + return { + "name": name, + "status": "error", + "error": error_msg, + "provider": "docker" + } + + async def update_vm(self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None) -> Dict[str, Any]: + """Update VM configuration. + + Note: Docker containers cannot be updated while running. + This method will return an error suggesting to recreate the container. + """ + return { + "name": name, + "status": "error", + "error": "Docker containers cannot be updated while running. Please stop and recreate the container with new options.", + "provider": "docker" + } + + async def get_ip(self, name: str, storage: Optional[str] = None, retry_delay: int = 2) -> str: + """Get the IP address of a VM, waiting indefinitely until it's available. + + Args: + name: Name of the VM to get the IP for + storage: Optional storage path override + retry_delay: Delay between retries in seconds (default: 2) + + Returns: + IP address of the VM when it becomes available + """ + logger.info(f"Getting IP address for container {name}") + + total_attempts = 0 + while True: + total_attempts += 1 + + try: + vm_info = await self.get_vm(name, storage) + + if vm_info["status"] == "error": + raise Exception(f"VM is in error state: {vm_info.get('error', 'Unknown error')}") + + # TODO: for now, return localhost + # it seems the docker container is not accessible from the host + # on WSL2, unless you port forward? not sure + if True: + logger.warning("Overriding container IP with localhost") + return "localhost" + + # Check if we got a valid IP + ip = vm_info.get("ip_address", None) + if ip and ip != "unknown" and not ip.startswith("0.0.0.0"): + logger.info(f"Got valid container IP address: {ip}") + return ip + + # For Docker containers, we can also use localhost if ports are mapped + if vm_info["status"] == "running" and vm_info.get("ports"): + logger.info(f"Container is running with port mappings, using localhost") + return "127.0.0.1" + + # Check the container status + status = vm_info.get("status", "unknown") + + if status == "stopped": + logger.info(f"Container status is {status}, but still waiting for it to start") + elif status != "running": + logger.info(f"Container is not running yet (status: {status}). Waiting...") + else: + logger.info("Container is running but no valid IP address yet. Waiting...") + + except Exception as e: + logger.warning(f"Error getting container {name} IP: {e}, continuing to wait...") + + # Wait before next retry + await asyncio.sleep(retry_delay) + + # Add progress log every 10 attempts + if total_attempts % 10 == 0: + logger.info(f"Still waiting for container {name} IP after {total_attempts} attempts...") + + async def __aenter__(self): + """Async context manager entry.""" + logger.debug("Entering DockerProvider context") + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit. + + This method handles cleanup of running containers if needed. + """ + logger.debug(f"Exiting DockerProvider context, handling exceptions: {exc_type}") + try: + # Optionally stop running containers on context exit + # For now, we'll leave containers running as they might be needed + # Users can manually stop them if needed + pass + except Exception as e: + logger.error(f"Error during DockerProvider cleanup: {e}") + if exc_type is None: + raise + return False diff --git a/libs/python/computer/computer/providers/factory.py b/libs/python/computer/computer/providers/factory.py index 98fcd9da..b95c2c61 100644 --- a/libs/python/computer/computer/providers/factory.py +++ b/libs/python/computer/computer/providers/factory.py @@ -134,5 +134,29 @@ class VMProviderFactory: "pywinsandbox is required for WinSandboxProvider. " "Please install it with 'pip install -U git+https://github.com/karkason/pywinsandbox.git'" ) from e + elif provider_type == VMProviderType.DOCKER: + try: + from .docker import DockerProvider, HAS_DOCKER + if not HAS_DOCKER: + raise ImportError( + "Docker is required for DockerProvider. " + "Please install Docker and ensure it is running." + ) + return DockerProvider( + port=port, + host=host, + storage=storage, + shared_path=shared_path, + image=image or "trycua/cua-ubuntu:latest", + verbose=verbose, + ephemeral=ephemeral, + vnc_port=noVNC_port + ) + except ImportError as e: + logger.error(f"Failed to import DockerProvider: {e}") + raise ImportError( + "Docker is required for DockerProvider. " + "Please install Docker and ensure it is running." + ) from e else: raise ValueError(f"Unsupported provider type: {provider_type}")