chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:19 +08:00
commit 91e75e620b
3227 changed files with 1307078 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
"""Cua Computer Interface for cross-platform computer control."""
import logging
import sys
__version__ = "0.1.0"
# Initialize logging
logger = logging.getLogger("computer")
# Initialize telemetry when the package is imported
try:
# Import from core telemetry
from cua_core.telemetry import (
is_telemetry_enabled,
record_event,
)
# Check if telemetry is enabled
if is_telemetry_enabled():
logger.info("Telemetry is enabled")
# Record package initialization
record_event(
"module_init",
{
"module": "computer",
"version": __version__,
"python_version": sys.version,
},
)
else:
logger.info("Telemetry is disabled")
except ImportError as e:
# Telemetry not available
logger.warning(f"Telemetry not available: {e}")
except Exception as e:
# Other issues with telemetry
logger.warning(f"Error initializing telemetry: {e}")
# Core components
from .computer import Computer
# Provider components
from .providers.base import VMProviderType
# PTY client
from .pty import PtyHandle, PtyInterface
__all__ = ["Computer", "PtyHandle", "PtyInterface", "VMProviderType"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
import asyncio
from .interface.models import Key, KeyType
class DioramaComputer:
"""
A Computer-compatible proxy for Diorama that sends commands over the ComputerInterface.
"""
def __init__(self, computer, apps):
"""
Initialize the DioramaComputer with a computer instance and list of apps.
Args:
computer: The computer instance to proxy commands through
apps: List of applications available in the diorama environment
"""
self.computer = computer
self.apps = apps
self.interface = DioramaComputerInterface(computer, apps)
self._initialized = False
async def __aenter__(self):
"""
Async context manager entry point.
Returns:
self: The DioramaComputer instance
"""
self._initialized = True
return self
async def run(self):
"""
Initialize and run the DioramaComputer if not already initialized.
Returns:
self: The DioramaComputer instance
"""
if not self._initialized:
await self.__aenter__()
return self
class DioramaComputerInterface:
"""
Diorama Interface proxy that sends diorama_cmds via the Computer's interface.
"""
def __init__(self, computer, apps):
"""
Initialize the DioramaComputerInterface.
Args:
computer: The computer instance to send commands through
apps: List of applications available in the diorama environment
"""
self.computer = computer
self.apps = apps
self._scene_size = None
async def _send_cmd(self, action, arguments=None):
"""
Send a command to the diorama interface through the computer.
Args:
action (str): The action/command to execute
arguments (dict, optional): Additional arguments for the command
Returns:
The result from the diorama command execution
Raises:
RuntimeError: If the computer interface is not initialized or command fails
"""
arguments = arguments or {}
arguments = {"app_list": self.apps, **arguments}
# Use the computer's interface (must be initialized)
iface = getattr(self.computer, "_interface", None)
if iface is None:
raise RuntimeError("Computer interface not initialized. Call run() first.")
result = await iface.diorama_cmd(action, arguments)
if not result.get("success"):
raise RuntimeError(
f"Diorama command failed: {result.get('error')}\n{result.get('trace')}"
)
return result.get("result")
async def screenshot(self, as_bytes=True):
"""
Take a screenshot of the diorama scene.
Args:
as_bytes (bool): If True, return image as bytes; if False, return PIL Image object
Returns:
bytes or PIL.Image: Screenshot data in the requested format
"""
import base64
from PIL import Image
result = await self._send_cmd("screenshot")
# assume result is a b64 string of an image
img_bytes = base64.b64decode(result)
import io
img = Image.open(io.BytesIO(img_bytes))
self._scene_size = img.size
return img_bytes if as_bytes else img
async def get_screen_size(self):
"""
Get the dimensions of the diorama scene.
Returns:
dict: Dictionary containing 'width' and 'height' keys with pixel dimensions
"""
if not self._scene_size:
await self.screenshot(as_bytes=False)
return {"width": self._scene_size[0], "height": self._scene_size[1]}
async def move_cursor(self, x, y):
"""
Move the cursor to the specified coordinates.
Args:
x (int): X coordinate to move cursor to
y (int): Y coordinate to move cursor to
"""
await self._send_cmd("move_cursor", {"x": x, "y": y})
async def left_click(self, x=None, y=None):
"""
Perform a left mouse click at the specified coordinates or current cursor position.
Args:
x (int, optional): X coordinate to click at. If None, clicks at current cursor position
y (int, optional): Y coordinate to click at. If None, clicks at current cursor position
"""
await self._send_cmd("left_click", {"x": x, "y": y})
async def right_click(self, x=None, y=None):
"""
Perform a right mouse click at the specified coordinates or current cursor position.
Args:
x (int, optional): X coordinate to click at. If None, clicks at current cursor position
y (int, optional): Y coordinate to click at. If None, clicks at current cursor position
"""
await self._send_cmd("right_click", {"x": x, "y": y})
async def double_click(self, x=None, y=None):
"""
Perform a double mouse click at the specified coordinates or current cursor position.
Args:
x (int, optional): X coordinate to double-click at. If None, clicks at current cursor position
y (int, optional): Y coordinate to double-click at. If None, clicks at current cursor position
"""
await self._send_cmd("double_click", {"x": x, "y": y})
async def scroll_up(self, clicks=1):
"""
Scroll up by the specified number of clicks.
Args:
clicks (int): Number of scroll clicks to perform upward. Defaults to 1
"""
await self._send_cmd("scroll_up", {"clicks": clicks})
async def scroll_down(self, clicks=1):
"""
Scroll down by the specified number of clicks.
Args:
clicks (int): Number of scroll clicks to perform downward. Defaults to 1
"""
await self._send_cmd("scroll_down", {"clicks": clicks})
async def drag_to(self, x, y, duration=0.5):
"""
Drag from the current cursor position to the specified coordinates.
Args:
x (int): X coordinate to drag to
y (int): Y coordinate to drag to
duration (float): Duration of the drag operation in seconds. Defaults to 0.5
"""
await self._send_cmd("drag_to", {"x": x, "y": y, "duration": duration})
async def get_cursor_position(self):
"""
Get the current cursor position.
Returns:
dict: Dictionary containing the current cursor coordinates
"""
return await self._send_cmd("get_cursor_position")
async def type_text(self, text):
"""
Type the specified text at the current cursor position.
Args:
text (str): The text to type
"""
await self._send_cmd("type_text", {"text": text})
async def press_key(self, key):
"""
Press a single key.
Args:
key: The key to press
"""
await self._send_cmd("press_key", {"key": key})
async def hotkey(self, *keys):
"""
Press multiple keys simultaneously as a hotkey combination.
Args:
*keys: Variable number of keys to press together. Can be Key enum instances or strings
Raises:
ValueError: If any key is not a Key enum or string type
"""
actual_keys = []
for key in keys:
if isinstance(key, Key):
actual_keys.append(key.value)
elif isinstance(key, str):
# Try to convert to enum if it matches a known key
key_or_enum = Key.from_string(key)
actual_keys.append(
key_or_enum.value if isinstance(key_or_enum, Key) else key_or_enum
)
else:
raise ValueError(f"Invalid key type: {type(key)}. Must be Key enum or string.")
await self._send_cmd("hotkey", {"keys": actual_keys})
async def to_screen_coordinates(self, x, y):
"""
Convert coordinates to screen coordinates.
Args:
x (int): X coordinate to convert
y (int): Y coordinate to convert
Returns:
dict: Dictionary containing the converted screen coordinates
"""
return await self._send_cmd("to_screen_coordinates", {"x": x, "y": y})
+490
View File
@@ -0,0 +1,490 @@
"""
Helper functions and decorators for the Computer module.
"""
import ast
import asyncio
import builtins
import importlib.util
import inspect
import logging
import os
import sys
from functools import wraps
from inspect import getsource
from textwrap import dedent
from types import FunctionType, ModuleType
from typing import Any, Awaitable, Callable, Dict, List, Set, TypedDict, TypeVar
try:
# Python 3.12+ has ParamSpec in typing
from typing import ParamSpec
except ImportError: # pragma: no cover
# Fallback for environments without ParamSpec in typing
from typing_extensions import ParamSpec # type: ignore
P = ParamSpec("P")
R = TypeVar("R")
class DependencyInfo(TypedDict):
import_statements: List[str]
definitions: List[tuple[str, Any]]
# Global reference to the default computer instance
_default_computer = None
# Global cache for function dependency analysis
_function_dependency_map: Dict[FunctionType, DependencyInfo] = {}
logger = logging.getLogger(__name__)
def set_default_computer(computer: Any) -> None:
"""
Set the default computer instance to be used by the remote decorator.
Args:
computer: The computer instance to use as default
"""
global _default_computer
_default_computer = computer
def sandboxed(
venv_name: str = "default",
computer: str = "default",
max_retries: int = 3,
) -> Callable[[Callable[P, R]], Callable[P, Awaitable[R]]]:
"""
Decorator that wraps a function to be executed remotely via computer.venv_exec
The function is automatically analyzed for dependencies (imports, helper functions,
constants, etc.) and reconstructed with all necessary code in the remote sandbox.
Args:
venv_name: Name of the virtual environment to execute in
computer: The computer instance to use, or "default" to use the globally set default
max_retries: Maximum number of retries for the remote execution
"""
def decorator(func: Callable[P, R]) -> Callable[P, Awaitable[R]]:
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Determine which computer instance to use
comp = computer if computer != "default" else _default_computer
if comp is None:
raise RuntimeError(
"No computer instance available. Either specify a computer instance or call set_default_computer() first."
)
for i in range(max_retries):
try:
return await comp.venv_exec(venv_name, func, *args, **kwargs)
except Exception as e:
logger.error(f"Attempt {i+1} failed: {e}")
await asyncio.sleep(1)
if i == max_retries - 1:
raise e
# Should be unreachable because we either returned or raised
raise RuntimeError("sandboxed wrapper reached unreachable code path")
return wrapper
return decorator
def _extract_import_statement(name: str, module: ModuleType) -> str:
"""Extract the original import statement for a module."""
module_name = module.__name__
if name == module_name.split(".")[0]:
return f"import {module_name}"
else:
return f"import {module_name} as {name}"
def _is_third_party_module(module_name: str) -> bool:
"""Check if a module is a third-party module."""
stdlib_modules = set(sys.stdlib_module_names) if hasattr(sys, "stdlib_module_names") else set()
if module_name in stdlib_modules:
return False
try:
spec = importlib.util.find_spec(module_name)
if spec is None:
return False
if spec.origin and ("site-packages" in spec.origin or "dist-packages" in spec.origin):
return True
return False
except (ImportError, ModuleNotFoundError, ValueError):
return False
def _is_project_import(module_name: str) -> bool:
"""Check if a module is a project-level import."""
if module_name.startswith("__relative_import_level_"):
return True
if module_name in sys.modules:
module = sys.modules[module_name]
if hasattr(module, "__file__") and module.__file__:
if "site-packages" not in module.__file__ and "dist-packages" not in module.__file__:
cwd = os.getcwd()
if module.__file__.startswith(cwd):
return True
return False
def _categorize_module(module_name: str) -> str:
"""Categorize a module as stdlib, third-party, or project."""
if module_name.startswith("__relative_import_level_"):
return "project"
elif module_name in (
set(sys.stdlib_module_names) if hasattr(sys, "stdlib_module_names") else set()
):
return "stdlib"
elif _is_third_party_module(module_name):
return "third_party"
elif _is_project_import(module_name):
return "project"
else:
return "unknown"
class _DependencyVisitor(ast.NodeVisitor):
"""AST visitor to extract imports and name references from a function."""
def __init__(self, function_name: str) -> None:
self.function_name = function_name
self.internal_imports: Set[str] = set()
self.internal_import_statements: List[str] = []
self.name_references: Set[str] = set()
self.local_names: Set[str] = set()
self.inside_function = False
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
if node.name == self.function_name and not self.inside_function:
self.inside_function = True
for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
self.local_names.add(arg.arg)
if node.args.vararg:
self.local_names.add(node.args.vararg.arg)
if node.args.kwarg:
self.local_names.add(node.args.kwarg.arg)
for child in node.body:
self.visit(child)
self.inside_function = False
else:
if self.inside_function:
self.local_names.add(node.name)
for child in node.body:
self.visit(child)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self.visit_FunctionDef(node) # type: ignore
def visit_Import(self, node: ast.Import) -> None:
if self.inside_function:
for alias in node.names:
module_name = alias.name.split(".")[0]
self.internal_imports.add(module_name)
imported_as = alias.asname if alias.asname else alias.name.split(".")[0]
self.local_names.add(imported_as)
self.internal_import_statements.append(ast.unparse(node))
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if self.inside_function:
if node.level == 0 and node.module:
module_name = node.module.split(".")[0]
self.internal_imports.add(module_name)
elif node.level > 0:
self.internal_imports.add(f"__relative_import_level_{node.level}__")
for alias in node.names:
imported_as = alias.asname if alias.asname else alias.name
self.local_names.add(imported_as)
self.internal_import_statements.append(ast.unparse(node))
self.generic_visit(node)
def visit_Name(self, node: ast.Name) -> None:
if self.inside_function:
if isinstance(node.ctx, ast.Load):
self.name_references.add(node.id)
elif isinstance(node.ctx, ast.Store):
self.local_names.add(node.id)
self.generic_visit(node)
def visit_ClassDef(self, node: ast.ClassDef) -> None:
if self.inside_function:
self.local_names.add(node.name)
self.generic_visit(node)
def visit_For(self, node: ast.For) -> None:
if self.inside_function and isinstance(node.target, ast.Name):
self.local_names.add(node.target.id)
self.generic_visit(node)
def visit_comprehension(self, node: ast.comprehension) -> None:
if self.inside_function and isinstance(node.target, ast.Name):
self.local_names.add(node.target.id)
self.generic_visit(node)
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
if self.inside_function and node.name:
self.local_names.add(node.name)
self.generic_visit(node)
def visit_With(self, node: ast.With) -> None:
if self.inside_function:
for item in node.items:
if item.optional_vars and isinstance(item.optional_vars, ast.Name):
self.local_names.add(item.optional_vars.id)
self.generic_visit(node)
def _traverse_and_collect_dependencies(func: FunctionType) -> DependencyInfo:
"""
Traverse a function and collect its dependencies.
Returns a dict with:
- import_statements: List of import statements needed
- definitions: List of (name, obj) tuples for helper functions/classes/constants
"""
source = dedent(getsource(func))
tree = ast.parse(source)
visitor = _DependencyVisitor(func.__name__)
visitor.visit(tree)
builtin_names = set(dir(builtins))
external_refs = (visitor.name_references - visitor.local_names) - builtin_names
import_statements = []
definitions = []
visited = set()
# Include all internal import statements
import_statements.extend(visitor.internal_import_statements)
# Analyze external references recursively
def analyze_object(obj: Any, name: str, depth: int = 0) -> None:
if depth > 20:
return
obj_id = id(obj)
if obj_id in visited:
return
visited.add(obj_id)
# Handle modules
if inspect.ismodule(obj):
import_stmt = _extract_import_statement(name, obj)
import_statements.append(import_stmt)
return
# Handle functions and classes
if (
inspect.isfunction(obj)
or inspect.isclass(obj)
or inspect.isbuiltin(obj)
or inspect.ismethod(obj)
):
obj_module = getattr(obj, "__module__", None)
if obj_module:
base_module = obj_module.split(".")[0]
module_category = _categorize_module(base_module)
# If from stdlib/third-party, just add import
if module_category in ("stdlib", "third_party"):
obj_name = getattr(obj, "__name__", name)
# Check if object is accessible by 'name' (in globals or closures)
is_accessible = False
if name in func.__globals__ and func.__globals__[name] is obj:
is_accessible = True
elif func.__closure__ and hasattr(func, "__code__"):
freevars = func.__code__.co_freevars
for i, var_name in enumerate(freevars):
if var_name == name and i < len(func.__closure__):
try:
if func.__closure__[i].cell_contents is obj:
is_accessible = True
break
except (ValueError, AttributeError):
pass
if is_accessible and name == obj_name:
# Direct import: from requests import get, from math import sqrt
import_statements.append(f"from {base_module} import {name}")
else:
# Module import: import requests
import_statements.append(f"import {base_module}")
return
try:
obj_tree = ast.parse(dedent(getsource(obj)))
obj_visitor = _DependencyVisitor(obj.__name__)
obj_visitor.visit(obj_tree)
obj_external_refs = obj_visitor.name_references - obj_visitor.local_names
obj_external_refs = obj_external_refs - builtin_names
# Add internal imports from this object
import_statements.extend(obj_visitor.internal_import_statements)
# Recursively analyze its dependencies
obj_globals = getattr(obj, "__globals__", None)
obj_closure = getattr(obj, "__closure__", None)
obj_code = getattr(obj, "__code__", None)
if obj_globals:
for ref_name in obj_external_refs:
ref_obj = None
# Check globals first
if ref_name in obj_globals:
ref_obj = obj_globals[ref_name]
# Check closure variables using co_freevars
elif obj_closure and obj_code:
freevars = obj_code.co_freevars
for i, var_name in enumerate(freevars):
if var_name == ref_name and i < len(obj_closure):
try:
ref_obj = obj_closure[i].cell_contents
break
except (ValueError, AttributeError):
pass
if ref_obj is not None:
analyze_object(ref_obj, ref_name, depth + 1)
# Add this object to definitions
if not inspect.ismodule(obj):
ref_module = getattr(obj, "__module__", None)
if ref_module:
ref_base_module = ref_module.split(".")[0]
ref_category = _categorize_module(ref_base_module)
if ref_category not in ("stdlib", "third_party"):
definitions.append((name, obj))
else:
definitions.append((name, obj))
except (OSError, TypeError):
pass
return
if isinstance(obj, (int, float, str, bool, list, dict, tuple, set, frozenset, type(None))):
definitions.append((name, obj))
# Analyze all external references
for name in external_refs:
obj = None
# First check globals
if name in func.__globals__:
obj = func.__globals__[name]
# Then check closure variables (sibling functions in enclosing scope)
elif func.__closure__ and func.__code__.co_freevars:
# Match closure variable names with cell contents
freevars = func.__code__.co_freevars
for i, var_name in enumerate(freevars):
if var_name == name and i < len(func.__closure__):
try:
obj = func.__closure__[i].cell_contents
break
except (ValueError, AttributeError):
# Cell is empty or doesn't have contents
pass
if obj is not None:
analyze_object(obj, name)
# Remove duplicate import statements
unique_imports = []
seen = set()
for stmt in import_statements:
if stmt not in seen:
seen.add(stmt)
unique_imports.append(stmt)
# Remove duplicate definitions
unique_definitions = []
seen_names = set()
for name, obj in definitions:
if name not in seen_names:
seen_names.add(name)
unique_definitions.append((name, obj))
return {
"import_statements": unique_imports,
"definitions": unique_definitions,
}
def generate_source_code(func: FunctionType) -> str:
"""
Generate complete source code for a function with all dependencies.
Args:
func: The function to generate source code for
Returns:
Complete Python source code as a string
"""
if func in _function_dependency_map:
info = _function_dependency_map[func]
else:
info = _traverse_and_collect_dependencies(func)
_function_dependency_map[func] = info
# Build source code
parts = []
# 1. Add imports
if info["import_statements"]:
parts.append("\n".join(info["import_statements"]))
# 2. Add definitions
for name, obj in info["definitions"]:
try:
if inspect.isfunction(obj):
source = dedent(getsource(obj))
tree = ast.parse(source)
if tree.body and isinstance(tree.body[0], (ast.FunctionDef, ast.AsyncFunctionDef)):
tree.body[0].decorator_list = []
source = ast.unparse(tree)
parts.append(source)
elif inspect.isclass(obj):
source = dedent(getsource(obj))
tree = ast.parse(source)
if tree.body and isinstance(tree.body[0], ast.ClassDef):
tree.body[0].decorator_list = []
source = ast.unparse(tree)
parts.append(source)
else:
parts.append(f"{name} = {repr(obj)}")
except (OSError, TypeError):
pass
# 3. Add main function (without decorators)
func_source = dedent(getsource(func))
tree = ast.parse(func_source)
if tree.body and isinstance(tree.body[0], (ast.FunctionDef, ast.AsyncFunctionDef)):
tree.body[0].decorator_list = []
func_source = ast.unparse(tree)
parts.append(func_source)
return "\n\n".join(parts)
@@ -0,0 +1,13 @@
"""
Interface package for Computer SDK.
"""
from .base import BaseComputerInterface
from .factory import InterfaceFactory
from .macos import MacOSComputerInterface
__all__ = [
"InterfaceFactory",
"BaseComputerInterface",
"MacOSComputerInterface",
]
@@ -0,0 +1,30 @@
from typing import Dict, Optional
from .generic import GenericComputerInterface
class AndroidComputerInterface(GenericComputerInterface):
"""Interface for Android."""
def __init__(
self,
ip_address: str,
username: str = "lume",
password: str = "lume",
api_key: Optional[str] = None,
vm_name: Optional[str] = None,
api_port: Optional[int] = None,
api_base_url: Optional[str] = None,
api_headers: Optional[Dict[str, str]] = None,
):
super().__init__(
ip_address,
username,
password,
api_key,
vm_name,
"computer.interface.android",
api_port,
api_base_url=api_base_url,
api_headers=api_headers,
)
@@ -0,0 +1,684 @@
"""Base interface for computer control."""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Tuple
from ..logger import Logger, LogLevel
from .models import CommandResult, MouseButton
class BaseComputerInterface(ABC):
"""Base class for computer control interfaces."""
def __init__(
self,
ip_address: str,
username: str = "lume",
password: str = "lume",
api_key: Optional[str] = None,
vm_name: Optional[str] = None,
):
"""Initialize interface.
Args:
ip_address: IP address of the computer to control
username: Username for authentication
password: Password for authentication
api_key: Optional API key for cloud authentication
vm_name: Optional VM name for cloud authentication
"""
self.ip_address = ip_address
self.username = username
self.password = password
self.api_key = api_key
self.vm_name = vm_name
self.logger = Logger("cua.interface", LogLevel.NORMAL)
# Optional default delay time between commands (in seconds)
self.delay: float = 0.0
@abstractmethod
async def wait_for_ready(self, timeout: int = 60) -> None:
"""Wait for interface to be ready.
Args:
timeout: Maximum time to wait in seconds
Raises:
TimeoutError: If interface is not ready within timeout
"""
pass
@abstractmethod
def close(self) -> None:
"""Close the interface connection."""
pass
def force_close(self) -> None:
"""Force close the interface connection.
By default, this just calls close(), but subclasses can override
to provide more forceful cleanup.
"""
self.close()
# Mouse Actions
@abstractmethod
async def mouse_down(
self,
x: Optional[int] = None,
y: Optional[int] = None,
button: "MouseButton" = "left",
delay: Optional[float] = None,
) -> None:
"""Press and hold a mouse button.
Args:
x: X coordinate to press at. If None, uses current cursor position.
y: Y coordinate to press at. If None, uses current cursor position.
button: Mouse button to press ('left', 'middle', 'right').
delay: Optional delay in seconds after the action
"""
pass
@abstractmethod
async def mouse_up(
self,
x: Optional[int] = None,
y: Optional[int] = None,
button: "MouseButton" = "left",
delay: Optional[float] = None,
) -> None:
"""Release a mouse button.
Args:
x: X coordinate to release at. If None, uses current cursor position.
y: Y coordinate to release at. If None, uses current cursor position.
button: Mouse button to release ('left', 'middle', 'right').
delay: Optional delay in seconds after the action
"""
pass
@abstractmethod
async def left_click(
self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None
) -> None:
"""Perform a left mouse button click.
Args:
x: X coordinate to click at. If None, uses current cursor position.
y: Y coordinate to click at. If None, uses current cursor position.
delay: Optional delay in seconds after the action
"""
pass
@abstractmethod
async def right_click(
self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None
) -> None:
"""Perform a right mouse button click.
Args:
x: X coordinate to click at. If None, uses current cursor position.
y: Y coordinate to click at. If None, uses current cursor position.
delay: Optional delay in seconds after the action
"""
pass
@abstractmethod
async def double_click(
self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None
) -> None:
"""Perform a double left mouse button click.
Args:
x: X coordinate to double-click at. If None, uses current cursor position.
y: Y coordinate to double-click at. If None, uses current cursor position.
delay: Optional delay in seconds after the action
"""
pass
@abstractmethod
async def move_cursor(self, x: int, y: int, delay: Optional[float] = None) -> None:
"""Move the cursor to the specified screen coordinates.
Args:
x: X coordinate to move cursor to.
y: Y coordinate to move cursor to.
delay: Optional delay in seconds after the action
"""
pass
@abstractmethod
async def drag_to(
self,
x: int,
y: int,
button: str = "left",
duration: float = 0.5,
delay: Optional[float] = None,
) -> None:
"""Drag from current position to specified coordinates.
Args:
x: The x coordinate to drag to
y: The y coordinate to drag to
button: The mouse button to use ('left', 'middle', 'right')
duration: How long the drag should take in seconds
delay: Optional delay in seconds after the action
"""
pass
@abstractmethod
async def drag(
self,
path: List[Tuple[int, int]],
button: str = "left",
duration: float = 0.5,
delay: Optional[float] = None,
) -> None:
"""Drag the cursor along a path of coordinates.
Args:
path: List of (x, y) coordinate tuples defining the drag path
button: The mouse button to use ('left', 'middle', 'right')
duration: Total time in seconds that the drag operation should take
delay: Optional delay in seconds after the action
"""
pass
# Keyboard Actions
@abstractmethod
async def key_down(self, key: str, delay: Optional[float] = None) -> None:
"""Press and hold a key.
Args:
key: The key to press and hold (e.g., 'a', 'shift', 'ctrl').
delay: Optional delay in seconds after the action.
"""
pass
@abstractmethod
async def key_up(self, key: str, delay: Optional[float] = None) -> None:
"""Release a previously pressed key.
Args:
key: The key to release (e.g., 'a', 'shift', 'ctrl').
delay: Optional delay in seconds after the action.
"""
pass
@abstractmethod
async def type_text(self, text: str, delay: Optional[float] = None) -> None:
"""Type the specified text string.
Args:
text: The text string to type.
delay: Optional delay in seconds after the action.
"""
pass
@abstractmethod
async def press_key(self, key: str, delay: Optional[float] = None) -> None:
"""Press and release a single key.
Args:
key: The key to press (e.g., 'a', 'enter', 'escape').
delay: Optional delay in seconds after the action.
"""
pass
@abstractmethod
async def hotkey(self, *keys: str, delay: Optional[float] = None) -> None:
"""Press multiple keys simultaneously (keyboard shortcut).
Args:
*keys: Variable number of keys to press together (e.g., 'ctrl', 'c').
delay: Optional delay in seconds after the action.
"""
pass
# Scrolling Actions
@abstractmethod
async def scroll(self, x: int, y: int, delay: Optional[float] = None) -> None:
"""Scroll the mouse wheel by specified amounts.
Args:
x: Horizontal scroll amount (positive = right, negative = left).
y: Vertical scroll amount (positive = up, negative = down).
delay: Optional delay in seconds after the action.
"""
pass
@abstractmethod
async def scroll_down(self, clicks: int = 1, delay: Optional[float] = None) -> None:
"""Scroll down by the specified number of clicks.
Args:
clicks: Number of scroll clicks to perform downward.
delay: Optional delay in seconds after the action.
"""
pass
@abstractmethod
async def scroll_up(self, clicks: int = 1, delay: Optional[float] = None) -> None:
"""Scroll up by the specified number of clicks.
Args:
clicks: Number of scroll clicks to perform upward.
delay: Optional delay in seconds after the action.
"""
pass
# Screen Actions
@abstractmethod
async def screenshot(self) -> bytes:
"""Take a screenshot.
Returns:
Raw bytes of the screenshot image
"""
pass
@abstractmethod
async def get_screen_size(self) -> Dict[str, int]:
"""Get the screen dimensions.
Returns:
Dict with 'width' and 'height' keys
"""
pass
@abstractmethod
async def get_cursor_position(self) -> Dict[str, int]:
"""Get the current cursor position on screen.
Returns:
Dict with 'x' and 'y' keys containing cursor coordinates.
"""
pass
# Clipboard Actions
@abstractmethod
async def copy_to_clipboard(self) -> str:
"""Get the current clipboard content.
Returns:
The text content currently stored in the clipboard.
"""
pass
@abstractmethod
async def set_clipboard(self, text: str) -> None:
"""Set the clipboard content to the specified text.
Args:
text: The text to store in the clipboard.
"""
pass
# File System Actions
@abstractmethod
async def file_exists(self, path: str) -> bool:
"""Check if a file exists at the specified path.
Args:
path: The file path to check.
Returns:
True if the file exists, False otherwise.
"""
pass
@abstractmethod
async def directory_exists(self, path: str) -> bool:
"""Check if a directory exists at the specified path.
Args:
path: The directory path to check.
Returns:
True if the directory exists, False otherwise.
"""
pass
@abstractmethod
async def list_dir(self, path: str) -> List[str]:
"""List the contents of a directory.
Args:
path: The directory path to list.
Returns:
List of file and directory names in the specified directory.
"""
pass
@abstractmethod
async def read_text(self, path: str) -> str:
"""Read the text contents of a file.
Args:
path: The file path to read from.
Returns:
The text content of the file.
"""
pass
@abstractmethod
async def write_text(self, path: str, content: str) -> None:
"""Write text content to a file.
Args:
path: The file path to write to.
content: The text content to write.
"""
pass
@abstractmethod
async def read_bytes(self, path: str, offset: int = 0, length: Optional[int] = None) -> bytes:
"""Read file binary contents with optional seeking support.
Args:
path: Path to the file
offset: Byte offset to start reading from (default: 0)
length: Number of bytes to read (default: None for entire file)
"""
pass
@abstractmethod
async def write_bytes(self, path: str, content: bytes) -> None:
"""Write binary content to a file.
Args:
path: The file path to write to.
content: The binary content to write.
"""
pass
@abstractmethod
async def delete_file(self, path: str) -> None:
"""Delete a file at the specified path.
Args:
path: The file path to delete.
"""
pass
@abstractmethod
async def create_dir(self, path: str) -> None:
"""Create a directory at the specified path.
Args:
path: The directory path to create.
"""
pass
@abstractmethod
async def delete_dir(self, path: str) -> None:
"""Delete a directory at the specified path.
Args:
path: The directory path to delete.
"""
pass
@abstractmethod
async def get_file_size(self, path: str) -> int:
"""Get the size of a file in bytes.
Args:
path: The file path to get the size of.
Returns:
The size of the file in bytes.
"""
pass
# Desktop actions
@abstractmethod
async def get_desktop_environment(self) -> str:
"""Get the current desktop environment.
Returns:
The name of the current desktop environment.
"""
pass
@abstractmethod
async def set_wallpaper(self, path: str) -> None:
"""Set the desktop wallpaper to the specified path.
Args:
path: The file path to set as wallpaper
"""
pass
# Window management
@abstractmethod
async def open(self, target: str) -> None:
"""Open a target using the system's default handler.
Typically opens files, folders, or URLs with the associated application.
Args:
target: The file path, folder path, or URL to open.
"""
pass
@abstractmethod
async def launch(self, app: str, args: List[str] | None = None) -> Optional[int]:
"""Launch an application with optional arguments.
Args:
app: The application executable or bundle identifier.
args: Optional list of arguments to pass to the application.
Returns:
Optional process ID (PID) of the launched application if available, otherwise None.
"""
pass
@abstractmethod
async def get_current_window_id(self) -> int | str:
"""Get the identifier of the currently active/focused window.
Returns:
A window identifier that can be used with other window management methods.
"""
pass
@abstractmethod
async def get_application_windows(self, app: str) -> List[int | str]:
"""Get all window identifiers for a specific application.
Args:
app: The application name, executable, or identifier to query.
Returns:
A list of window identifiers belonging to the specified application.
"""
pass
@abstractmethod
async def get_window_name(self, window_id: int | str) -> str:
"""Get the title/name of a window.
Args:
window_id: The window identifier.
Returns:
The window's title or name string.
"""
pass
@abstractmethod
async def get_window_size(self, window_id: int | str) -> tuple[int, int]:
"""Get the size of a window in pixels.
Args:
window_id: The window identifier.
Returns:
A tuple of (width, height) representing the window size in pixels.
"""
pass
@abstractmethod
async def get_window_position(self, window_id: int | str) -> tuple[int, int]:
"""Get the screen position of a window.
Args:
window_id: The window identifier.
Returns:
A tuple of (x, y) representing the window's top-left corner in screen coordinates.
"""
pass
@abstractmethod
async def set_window_size(self, window_id: int | str, width: int, height: int) -> None:
"""Set the size of a window in pixels.
Args:
window_id: The window identifier.
width: Desired width in pixels.
height: Desired height in pixels.
"""
pass
@abstractmethod
async def set_window_position(self, window_id: int | str, x: int, y: int) -> None:
"""Move a window to a specific position on the screen.
Args:
window_id: The window identifier.
x: X coordinate for the window's top-left corner.
y: Y coordinate for the window's top-left corner.
"""
pass
@abstractmethod
async def maximize_window(self, window_id: int | str) -> None:
"""Maximize a window.
Args:
window_id: The window identifier.
"""
pass
@abstractmethod
async def minimize_window(self, window_id: int | str) -> None:
"""Minimize a window.
Args:
window_id: The window identifier.
"""
pass
@abstractmethod
async def activate_window(self, window_id: int | str) -> None:
"""Bring a window to the foreground and focus it.
Args:
window_id: The window identifier.
"""
pass
@abstractmethod
async def close_window(self, window_id: int | str) -> None:
"""Close a window.
Args:
window_id: The window identifier.
"""
pass
# Convenience aliases
async def get_window_title(self, window_id: int | str) -> str:
"""Convenience alias for get_window_name().
Args:
window_id: The window identifier.
Returns:
The window's title or name string.
"""
return await self.get_window_name(window_id)
async def window_size(self, window_id: int | str) -> tuple[int, int]:
"""Convenience alias for get_window_size().
Args:
window_id: The window identifier.
Returns:
A tuple of (width, height) representing the window size in pixels.
"""
return await self.get_window_size(window_id)
# Shell actions
@abstractmethod
async def run_command(self, command: str) -> CommandResult:
"""Run shell command and return structured result.
Executes a shell command using subprocess.run with shell=True and check=False.
The command is run in the target environment and captures both stdout and stderr.
Args:
command (str): The shell command to execute
Returns:
CommandResult: A structured result containing:
- stdout (str): Standard output from the command
- stderr (str): Standard error from the command
- returncode (int): Exit code from the command (0 indicates success)
Raises:
RuntimeError: If the command execution fails at the system level
Example:
result = await interface.run_command("ls -la")
if result.returncode == 0:
print(f"Output: {result.stdout}")
else:
print(f"Error: {result.stderr}, Exit code: {result.returncode}")
"""
pass
# Accessibility Actions
@abstractmethod
async def get_accessibility_tree(self) -> Dict:
"""Get the accessibility tree of the current screen.
Returns:
Dict containing the hierarchical accessibility information of screen elements.
"""
pass
@abstractmethod
async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
"""Convert screenshot coordinates to screen coordinates.
Args:
x: X coordinate in screenshot space
y: Y coordinate in screenshot space
Returns:
tuple[float, float]: (x, y) coordinates in screen space
"""
pass
@abstractmethod
async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]:
"""Convert screen coordinates to screenshot coordinates.
Args:
x: X coordinate in screen space
y: Y coordinate in screen space
Returns:
tuple[float, float]: (x, y) coordinates in screenshot space
"""
pass
@@ -0,0 +1,69 @@
"""Factory for creating computer interfaces."""
from typing import Dict, Literal, Optional
from .base import BaseComputerInterface
OSType = Literal["macos", "linux", "windows", "android"]
class InterfaceFactory:
"""Factory for creating OS-specific computer interfaces."""
@staticmethod
def create_interface_for_os(
os: OSType,
ip_address: str,
api_port: Optional[int] = None,
api_key: Optional[str] = None,
vm_name: Optional[str] = None,
api_base_url: Optional[str] = None,
api_headers: Optional[Dict[str, str]] = None,
) -> BaseComputerInterface:
"""Create an interface for the specified OS.
Args:
os: Operating system type ('macos', 'linux', or 'windows')
ip_address: IP address of the computer to control
api_port: Optional API port of the computer to control
api_key: Optional API key for cloud authentication
vm_name: Optional VM name for cloud authentication
api_base_url: Optional explicit base URL for the computer-server, e.g. when
it sits behind an authenticated, path-prefixed reverse proxy. When set,
REST/WebSocket URIs are derived from it instead of ip_address + port.
api_headers: Optional extra HTTP headers (e.g. ``Authorization: Bearer ...``)
sent on every REST request and the WebSocket upgrade.
Returns:
BaseComputerInterface: The appropriate interface for the OS
Raises:
ValueError: If the OS type is not supported
"""
common_kwargs = dict(
api_key=api_key,
vm_name=vm_name,
api_port=api_port,
api_base_url=api_base_url,
api_headers=api_headers,
)
if os == "macos":
from .macos import MacOSComputerInterface
return MacOSComputerInterface(ip_address, **common_kwargs)
elif os == "linux":
from .linux import LinuxComputerInterface
return LinuxComputerInterface(ip_address, **common_kwargs)
elif os == "windows":
from .windows import WindowsComputerInterface
return WindowsComputerInterface(ip_address, **common_kwargs)
elif os == "android":
from .android import AndroidComputerInterface
return AndroidComputerInterface(ip_address, **common_kwargs)
else:
raise ValueError(f"Unsupported OS type: {os}")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
from typing import Dict, Optional
from .generic import GenericComputerInterface
class LinuxComputerInterface(GenericComputerInterface):
"""Interface for Linux."""
def __init__(
self,
ip_address: str,
username: str = "lume",
password: str = "lume",
api_key: Optional[str] = None,
vm_name: Optional[str] = None,
api_port: Optional[int] = None,
api_base_url: Optional[str] = None,
api_headers: Optional[Dict[str, str]] = None,
):
super().__init__(
ip_address,
username,
password,
api_key,
vm_name,
"computer.interface.linux",
api_port,
api_base_url=api_base_url,
api_headers=api_headers,
)
@@ -0,0 +1,36 @@
from typing import Dict, Optional
from .generic import GenericComputerInterface
class MacOSComputerInterface(GenericComputerInterface):
"""Interface for macOS."""
def __init__(
self,
ip_address: str,
username: str = "lume",
password: str = "lume",
api_key: Optional[str] = None,
vm_name: Optional[str] = None,
api_port: Optional[int] = None,
api_base_url: Optional[str] = None,
api_headers: Optional[Dict[str, str]] = None,
):
super().__init__(
ip_address,
username,
password,
api_key,
vm_name,
"computer.interface.macos",
api_port,
api_base_url=api_base_url,
api_headers=api_headers,
)
async def diorama_cmd(self, action: str, arguments: Optional[dict] = None) -> dict:
"""Send a diorama command to the server (macOS only)."""
return await self._send_command(
"diorama_cmd", {"action": action, "arguments": arguments or {}}
)
@@ -0,0 +1,145 @@
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, List, Literal, TypedDict, Union
@dataclass
class CommandResult:
stdout: str
stderr: str
returncode: int
def __init__(self, stdout: str, stderr: str, returncode: int):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
# Navigation key literals
NavigationKey = Literal["pagedown", "pageup", "home", "end", "left", "right", "up", "down"]
# Special key literals
SpecialKey = Literal["enter", "esc", "tab", "space", "backspace", "del"]
# Modifier key literals
ModifierKey = Literal["ctrl", "alt", "shift", "win", "command", "option"]
# Function key literals
FunctionKey = Literal["f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12"]
class Key(Enum):
"""Keyboard keys that can be used with press_key.
These key names follow a consistent cross-platform keyboard key naming convention.
"""
# Navigation
PAGE_DOWN = "pagedown"
PAGE_UP = "pageup"
HOME = "home"
END = "end"
LEFT = "left"
RIGHT = "right"
UP = "up"
DOWN = "down"
# Special keys
RETURN = "enter"
ENTER = "enter"
ESCAPE = "esc"
ESC = "esc"
TAB = "tab"
SPACE = "space"
BACKSPACE = "backspace"
DELETE = "del"
# Modifier keys
ALT = "alt"
CTRL = "ctrl"
SHIFT = "shift"
WIN = "win"
COMMAND = "command"
OPTION = "option"
# Function keys
F1 = "f1"
F2 = "f2"
F3 = "f3"
F4 = "f4"
F5 = "f5"
F6 = "f6"
F7 = "f7"
F8 = "f8"
F9 = "f9"
F10 = "f10"
F11 = "f11"
F12 = "f12"
@classmethod
def from_string(cls, key: str) -> "Key | str":
"""Convert a string key name to a Key enum value.
Args:
key: String key name to convert
Returns:
Key enum value if the string matches a known key,
otherwise returns the original string for single character keys
"""
# Map common alternative names to enum values
key_mapping = {
"page_down": cls.PAGE_DOWN,
"page down": cls.PAGE_DOWN,
"pagedown": cls.PAGE_DOWN,
"page_up": cls.PAGE_UP,
"page up": cls.PAGE_UP,
"pageup": cls.PAGE_UP,
"return": cls.RETURN,
"enter": cls.ENTER,
"escape": cls.ESCAPE,
"esc": cls.ESC,
"delete": cls.DELETE,
"del": cls.DELETE,
# Modifier key mappings
"alt": cls.ALT,
"ctrl": cls.CTRL,
"control": cls.CTRL,
"shift": cls.SHIFT,
"win": cls.WIN,
"windows": cls.WIN,
"super": cls.WIN,
"command": cls.COMMAND,
"cmd": cls.COMMAND,
"": cls.COMMAND,
"option": cls.OPTION,
"": cls.OPTION,
}
normalized = key.lower().strip()
return key_mapping.get(normalized, key)
# Combined key type
KeyType = Union[Key, NavigationKey, SpecialKey, ModifierKey, FunctionKey, str]
# Key type for mouse actions
MouseButton = Literal["left", "right", "middle"]
class AccessibilityWindow(TypedDict):
"""Information about a window in the accessibility tree."""
app_name: str
pid: int
frontmost: bool
has_windows: bool
windows: List[Dict[str, Any]]
class AccessibilityTree(TypedDict):
"""Complete accessibility tree information."""
success: bool
frontmost_application: str
windows: List[AccessibilityWindow]
@@ -0,0 +1,30 @@
from typing import Dict, Optional
from .generic import GenericComputerInterface
class WindowsComputerInterface(GenericComputerInterface):
"""Interface for Windows."""
def __init__(
self,
ip_address: str,
username: str = "lume",
password: str = "lume",
api_key: Optional[str] = None,
vm_name: Optional[str] = None,
api_port: Optional[int] = None,
api_base_url: Optional[str] = None,
api_headers: Optional[Dict[str, str]] = None,
):
super().__init__(
ip_address,
username,
password,
api_key,
vm_name,
"computer.interface.windows",
api_port,
api_base_url=api_base_url,
api_headers=api_headers,
)
+84
View File
@@ -0,0 +1,84 @@
"""Logging utilities for the Computer module."""
import logging
from enum import IntEnum
# Keep LogLevel for backward compatibility, but it will be deprecated
class LogLevel(IntEnum):
"""Log levels for logging. Deprecated - use standard logging levels instead."""
QUIET = 0 # Only warnings and errors
NORMAL = 1 # Info level, standard output
VERBOSE = 2 # More detailed information
DEBUG = 3 # Full debug information
# Map LogLevel to standard logging levels for backward compatibility
LOGLEVEL_MAP = {
LogLevel.QUIET: logging.WARNING,
LogLevel.NORMAL: logging.INFO,
LogLevel.VERBOSE: logging.DEBUG,
LogLevel.DEBUG: logging.DEBUG,
}
class Logger:
"""Logger class for Computer."""
def __init__(self, name: str, verbosity: int):
"""Initialize the logger.
Args:
name: The name of the logger.
verbosity: The log level (use standard logging levels like logging.INFO).
For backward compatibility, LogLevel enum values are also accepted.
"""
self.logger = logging.getLogger(name)
# Convert LogLevel enum to standard logging level if needed
if isinstance(verbosity, LogLevel):
self.verbosity = LOGLEVEL_MAP.get(verbosity, logging.INFO)
else:
self.verbosity = verbosity
self._configure()
def _configure(self):
"""Configure the logger based on log level."""
# Set the logging level directly
self.logger.setLevel(self.verbosity)
# Log the verbosity level that was set
if self.verbosity <= logging.DEBUG:
self.logger.info("Logger set to DEBUG level")
elif self.verbosity <= logging.INFO:
self.logger.info("Logger set to INFO level")
elif self.verbosity <= logging.WARNING:
self.logger.warning("Logger set to WARNING level")
elif self.verbosity <= logging.ERROR:
self.logger.warning("Logger set to ERROR level")
elif self.verbosity <= logging.CRITICAL:
self.logger.warning("Logger set to CRITICAL level")
def debug(self, message: str):
"""Log a debug message if log level is DEBUG or lower."""
self.logger.debug(message)
def info(self, message: str):
"""Log an info message if log level is INFO or lower."""
self.logger.info(message)
def verbose(self, message: str):
"""Log a verbose message between INFO and DEBUG levels."""
# Since there's no standard verbose level,
# use debug level with [VERBOSE] prefix for backward compatibility
self.logger.debug(f"[VERBOSE] {message}")
def warning(self, message: str):
"""Log a warning message."""
self.logger.warning(message)
def error(self, message: str):
"""Log an error message."""
self.logger.error(message)
+53
View File
@@ -0,0 +1,53 @@
"""Models for computer configuration."""
from dataclasses import dataclass
from typing import Any, Dict, Optional
# Import base provider interface
from .providers.base import BaseVMProvider
@dataclass
class Display:
"""Display configuration."""
width: int
height: int
@dataclass
class Image:
"""VM image configuration."""
image: str
tag: str
name: str
@dataclass
class Computer:
"""Computer configuration."""
image: str
tag: str
name: str
display: Display
memory: str
cpu: str
vm_provider: Optional[BaseVMProvider] = None
# @property # Remove the property decorator
async def get_ip(self) -> Optional[str]:
"""Get the IP address of the VM."""
if not self.vm_provider:
return None
vm = await self.vm_provider.get_vm(self.name)
# Handle both object attribute and dictionary access for ip_address
if vm:
if isinstance(vm, dict):
return vm.get("ip_address")
else:
# Access as attribute for object-based return values
return getattr(vm, "ip_address", None)
return None
@@ -0,0 +1,186 @@
"""
OpenTelemetry instrumentation wrapper for computer interface.
Records metrics for computer actions including:
- Latency: Duration of each action
- Traffic: Count of actions by type
- Errors: Action failures
"""
import time
from typing import Any, Callable, Optional
from .interface.base import BaseComputerInterface
# Import OTEL functions - available when cua-core[telemetry] is installed
try:
from cua_core.telemetry import (
create_span,
is_otel_enabled,
record_error,
record_operation,
)
OTEL_AVAILABLE = True
except ImportError:
OTEL_AVAILABLE = False
def is_otel_enabled() -> bool:
return False
# Actions that should be instrumented
INSTRUMENTED_ACTIONS = {
# Mouse actions
"left_click",
"right_click",
"double_click",
"move_cursor",
"scroll",
"drag",
"mouse_down",
"mouse_up",
# Keyboard actions
"type_text",
"press_key",
"hotkey",
"key_down",
"key_up",
# Screen actions
"screenshot",
"get_screen_size",
"get_cursor_position",
# Clipboard
"get_clipboard",
"set_clipboard",
# Shell/commands
"run_shell_command",
"run_command",
"open_url",
"open_file",
"open_application",
# File operations
"file_exists",
"directory_exists",
"list_directory",
"read_file",
"write_file",
# Accessibility
"get_accessibility_tree",
"find_element",
}
class OtelInterfaceWrapper:
"""
Wrapper that instruments computer interface methods with OpenTelemetry.
Records:
- Duration of each action (latency)
- Count of actions (traffic)
- Errors by action type
"""
def __init__(
self,
original_interface: BaseComputerInterface,
os_type: str = "unknown",
):
"""
Initialize the OTEL wrapper.
Args:
original_interface: The original computer interface
os_type: The OS type (macos, linux, windows)
"""
self._original_interface = original_interface
self._os_type = os_type
self._enabled = OTEL_AVAILABLE and is_otel_enabled()
def __getattr__(self, name: str) -> Any:
"""
Delegate attribute access to the original interface.
For instrumented methods, wrap them to record metrics.
"""
attr = getattr(self._original_interface, name)
# Only instrument async methods that are in our list
if name in INSTRUMENTED_ACTIONS and callable(attr):
return self._wrap_method(name, attr)
return attr
def _wrap_method(self, name: str, method: Callable) -> Callable:
"""
Wrap a method to record OTEL metrics.
Args:
name: Method name
method: Original method
Returns:
Wrapped method that records metrics
"""
async def instrumented(*args: Any, **kwargs: Any) -> Any:
if not self._enabled:
return await method(*args, **kwargs)
start_time = time.perf_counter()
status = "success"
error_type = None
try:
with create_span(
f"computer.{name}",
{"action": name, "os_type": self._os_type},
):
result = await method(*args, **kwargs)
return result
except Exception as e:
status = "error"
error_type = type(e).__name__
raise
finally:
duration = time.perf_counter() - start_time
# Record operation metrics
if OTEL_AVAILABLE:
record_operation(
operation=f"computer.action.{name}",
duration_seconds=duration,
status=status,
os_type=self._os_type,
)
if error_type:
record_error(
error_type=error_type,
operation=f"computer.action.{name}",
)
return instrumented
def wrap_interface_with_otel(
interface: BaseComputerInterface,
os_type: str = "unknown",
) -> BaseComputerInterface:
"""
Wrap a computer interface with OTEL instrumentation.
Args:
interface: The original interface
os_type: The OS type for labeling
Returns:
The wrapped interface (or original if OTEL disabled)
"""
if not OTEL_AVAILABLE or not is_otel_enabled():
return interface
return OtelInterfaceWrapper(interface, os_type) # type: ignore
@@ -0,0 +1,4 @@
"""Provider implementations for different VM backends."""
# Import specific providers only when needed to avoid circular imports
__all__ = [] # Let each provider module handle its own exports
@@ -0,0 +1,134 @@
"""Base provider interface for VM backends."""
import abc
from enum import StrEnum
from typing import Any, AsyncContextManager, Dict, Optional
from .types import ListVMsResponse
class VMProviderType(StrEnum):
"""Enum of supported VM provider types."""
LUME = "lume"
LUMIER = "lumier"
CLOUD = "cloud"
CLOUDV2 = "cloudv2"
WINSANDBOX = "winsandbox"
DOCKER = "docker"
UNKNOWN = "unknown"
class BaseVMProvider(AsyncContextManager):
"""Base interface for VM providers.
All VM provider implementations must implement this interface.
"""
@property
@abc.abstractmethod
def provider_type(self) -> VMProviderType:
"""Get the provider type."""
pass
@abc.abstractmethod
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.
"""
pass
@abc.abstractmethod
async def list_vms(self) -> ListVMsResponse:
"""List all available VMs.
Returns:
ListVMsResponse: A list of minimal VM objects as defined in
`computer.providers.types.MinimalVM`.
"""
pass
@abc.abstractmethod
async def run_vm(
self, image: str, name: str, run_opts: Dict[str, Any], storage: Optional[str] = None
) -> Dict[str, Any]:
"""Run a VM by name with the given options.
Args:
image: Name/tag of the image to use
name: Name of the VM to run
run_opts: Dictionary of run options (memory, cpu, etc.)
storage: Optional storage path override. If provided, this will be used
instead of the provider's default storage path.
Returns:
Dictionary with VM run status and information
"""
pass
@abc.abstractmethod
async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Stop a VM by name.
Args:
name: Name of the VM to stop
storage: Optional storage path override. If provided, this will be used
instead of the provider's default storage path.
Returns:
Dictionary with VM stop status and information
"""
pass
@abc.abstractmethod
async def restart_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Restart a VM by name.
Args:
name: Name of the VM to restart
storage: Optional storage path override. If provided, this will be used
instead of the provider's default storage path.
Returns:
Dictionary with VM restart status and information
"""
pass
@abc.abstractmethod
async def update_vm(
self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None
) -> Dict[str, Any]:
"""Update VM configuration.
Args:
name: Name of the VM to update
update_opts: Dictionary of update options (memory, cpu, etc.)
storage: Optional storage path override. If provided, this will be used
instead of the provider's default storage path.
Returns:
Dictionary with VM update status and information
"""
pass
@abc.abstractmethod
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. If provided, this will be used
instead of the provider's default storage path.
retry_delay: Delay between retries in seconds (default: 2)
Returns:
IP address of the VM when it becomes available
"""
pass
@@ -0,0 +1,6 @@
"""CloudProvider module for interacting with cloud-based virtual machines."""
from .provider import CloudProvider
from .providerv2 import CloudV2Provider
__all__ = ["CloudProvider", "CloudV2Provider"]
@@ -0,0 +1,321 @@
"""Cloud VM provider implementation using Cua Public API.
Implements the following public API endpoints:
- GET /v1/vms
- POST /v1/vms/:name/start
- POST /v1/vms/:name/stop
- POST /v1/vms/:name/restart
"""
import logging
from typing import Any, Dict, List, Optional
from ..base import BaseVMProvider, VMProviderType
from ..types import ListVMsResponse, MinimalVM
# Setup logging
logger = logging.getLogger(__name__)
import asyncio
import os
from urllib.parse import urlparse
import aiohttp
from cua_core.http import cua_version_headers
DEFAULT_API_BASE = os.getenv("CUA_API_BASE", "https://api.cua.ai")
class CloudProvider(BaseVMProvider):
"""Cloud VM Provider implementation."""
def __init__(
self,
api_key: Optional[str] = None,
verbose: bool = False,
api_base: Optional[str] = None,
**kwargs,
):
"""
Args:
api_key: API key for authentication (defaults to CUA_API_KEY environment variable)
name: Name of the VM
verbose: Enable verbose logging
"""
# Fall back to environment variable if api_key not provided
if api_key is None:
api_key = os.getenv("CUA_API_KEY")
assert (
api_key
), "api_key required for CloudProvider (provide via parameter or CUA_API_KEY environment variable)"
self.api_key = api_key
self.verbose = verbose
self.api_base = (api_base or DEFAULT_API_BASE).rstrip("/")
# Host caching dictionary: {vm_name: host_string}
self._host_cache: Dict[str, str] = {}
def _base_headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
**cua_version_headers(),
}
@property
def provider_type(self) -> VMProviderType:
return VMProviderType.CLOUD
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
async def get_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Get VM information via the public API and optionally probe for os_type.
Uses GET /v1/vms/:name as source of truth for VM existence and status,
then probes the computer-server for supplementary info (os_type).
"""
hostname = await self._get_host_for_vm(name)
api_url = f"https://{hostname}:8443"
# Query the API for authoritative VM info
url = f"{self.api_base}/v1/vms/{name}"
headers = self._base_headers()
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp:
if resp.status == 404:
return {"name": name, "status": "not_found", "api_url": api_url}
if resp.status == 401:
return {"name": name, "status": "unauthorized", "api_url": api_url}
if resp.status != 200:
text = await resp.text()
logger.error(f"get_vm API error: HTTP {resp.status} - {text}")
return {"name": name, "status": "unknown", "api_url": api_url}
vm_info = await resp.json(content_type=None)
except Exception as e:
logger.error(f"get_vm API request failed: {e}")
return {"name": name, "status": "unknown", "api_url": api_url}
# Enrich with host-derived URLs
host = vm_info.get("host")
if isinstance(host, str) and host:
self._host_cache[name] = host
hostname = host
api_url = f"https://{hostname}:8443"
vm_info["api_url"] = api_url
password = vm_info.get("password")
if not vm_info.get("vnc_url") and isinstance(password, str) and password:
vm_info["vnc_url"] = f"https://{hostname}/vnc.html?autoconnect=true&password={password}"
# Map "os" from API to "os_type" for backward compatibility
if vm_info.get("os") and not vm_info.get("os_type"):
vm_info["os_type"] = vm_info["os"]
return vm_info
async def list_vms(self) -> ListVMsResponse:
url = f"{self.api_base}/v1/vms"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
try:
data = await resp.json(content_type=None)
except Exception:
text = await resp.text()
logger.error(f"Failed to parse list_vms JSON: {text}")
return []
if isinstance(data, list):
# Enrich with convenience URLs when possible.
enriched: List[Dict[str, Any]] = []
for item in data:
vm = dict(item) if isinstance(item, dict) else {}
name = vm.get("name")
password = vm.get("password")
api_host = vm.get("host") # Read host from API response
if isinstance(name, str) and name:
# Use host from API if available, otherwise fallback to legacy format
if isinstance(api_host, str) and api_host:
host = api_host
# Cache the host for this VM
self._host_cache[name] = host
else:
# Legacy fallback
host = f"{name}.containers.cloud.trycua.com"
# Cache the legacy host
self._host_cache[name] = host
# api_url: always set if missing
if not vm.get("api_url"):
vm["api_url"] = f"https://{host}:8443"
# vnc_url: only when password available
if not vm.get("vnc_url") and isinstance(password, str) and password:
vm["vnc_url"] = (
f"https://{host}/vnc.html?autoconnect=true&password={password}"
)
enriched.append(vm)
return enriched # type: ignore[return-value]
logger.warning("Unexpected response for list_vms; expected list")
return []
elif resp.status == 401:
logger.error("Unauthorized: invalid Cua API key for list_vms")
return []
else:
text = await resp.text()
logger.error(f"list_vms failed: HTTP {resp.status} - {text}")
return []
async def run_vm(
self,
name: str,
image: Optional[str] = None,
run_opts: Optional[Dict[str, Any]] = None,
storage: Optional[str] = None,
) -> Dict[str, Any]:
"""Start a VM via public API. Returns a minimal status."""
url = f"{self.api_base}/v1/vms/{name}/start"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
if resp.status in (200, 201, 202, 204):
return {"name": name, "status": "starting"}
elif resp.status == 404:
return {"name": name, "status": "not_found"}
elif resp.status == 401:
return {"name": name, "status": "unauthorized"}
else:
text = await resp.text()
return {"name": name, "status": "error", "message": text}
async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Stop a VM via public API."""
url = f"{self.api_base}/v1/vms/{name}/stop"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
if resp.status in (200, 202):
# Spec says 202 with {"status":"stopping"}
body_status: Optional[str] = None
try:
data = await resp.json(content_type=None)
body_status = data.get("status") if isinstance(data, dict) else None
except Exception:
body_status = None
return {"name": name, "status": body_status or "stopping"}
elif resp.status == 404:
return {"name": name, "status": "not_found"}
elif resp.status == 401:
return {"name": name, "status": "unauthorized"}
else:
text = await resp.text()
return {"name": name, "status": "error", "message": text}
async def restart_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Restart a VM via public API."""
url = f"{self.api_base}/v1/vms/{name}/restart"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
if resp.status in (200, 202):
# Spec says 202 with {"status":"restarting"}
body_status: Optional[str] = None
try:
data = await resp.json(content_type=None)
body_status = data.get("status") if isinstance(data, dict) else None
except Exception:
body_status = None
return {"name": name, "status": body_status or "restarting"}
elif resp.status == 404:
return {"name": name, "status": "not_found"}
elif resp.status == 401:
return {"name": name, "status": "unauthorized"}
else:
text = await resp.text()
return {"name": name, "status": "error", "message": text}
async def update_vm(
self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None
) -> Dict[str, Any]:
logger.warning("CloudProvider.update_vm is not implemented via public API")
return {
"name": name,
"status": "unchanged",
"message": "update_vm not supported by public API",
}
async def _get_host_for_vm(self, name: str) -> str:
"""
Get the host for a VM, trying multiple approaches:
1. Check cache first
2. Try to refresh cache by calling list_vms
3. Try .sandbox.cua.ai format
4. Fallback to legacy .containers.cloud.trycua.com format
Args:
name: VM name
Returns:
Host string for the VM
"""
# Check cache first
if name in self._host_cache:
return self._host_cache[name]
# Try to refresh cache by calling list_vms
try:
await self.list_vms()
# Check cache again after refresh
if name in self._host_cache:
return self._host_cache[name]
except Exception as e:
logger.warning(f"Failed to refresh VM list for host lookup: {e}")
# Try .sandbox.cua.ai format first
sandbox_host = f"{name}.sandbox.cua.ai"
if await self._test_host_connectivity(sandbox_host):
self._host_cache[name] = sandbox_host
return sandbox_host
# Fallback to legacy format
legacy_host = f"{name}.containers.cloud.trycua.com"
# Cache the legacy host
self._host_cache[name] = legacy_host
return legacy_host
async def _test_host_connectivity(self, hostname: str) -> bool:
"""
Test if a host is reachable by trying to connect to its status endpoint.
Args:
hostname: Host to test
Returns:
True if host is reachable, False otherwise
"""
try:
timeout = aiohttp.ClientTimeout(total=2) # Short timeout for connectivity test
async with aiohttp.ClientSession(timeout=timeout) as session:
url = f"https://{hostname}:8443/status"
async with session.get(url, allow_redirects=False) as resp:
# Any response (even error) means the host is reachable
return True
except Exception:
return False
async def get_ip(
self, name: Optional[str] = None, storage: Optional[str] = None, retry_delay: int = 2
) -> str:
"""
Return the VM's host address, trying to use cached host from API or falling back to legacy format.
Uses the provided 'name' argument (the VM name requested by the caller).
"""
if name is None:
raise ValueError("VM name is required for CloudProvider.get_ip")
return await self._get_host_for_vm(name)
@@ -0,0 +1,263 @@
"""Cloud V2 provider implementation using cua.sh domain.
Uses the new domain format:
- API: {name}-api.cua.sh:443
- VNC: {name}-vnc.cua.sh:443
"""
import logging
from typing import Any, Dict, List, Optional
from ..base import BaseVMProvider, VMProviderType
from ..types import ListVMsResponse
# Setup logging
logger = logging.getLogger(__name__)
import asyncio
import os
import aiohttp
from cua_core.http import cua_version_headers
DEFAULT_API_BASE = os.getenv("CUA_API_BASE", "https://api.cua.ai")
class CloudV2Provider(BaseVMProvider):
"""Cloud V2 Provider implementation using cua.sh domain."""
def __init__(
self,
api_key: Optional[str] = None,
verbose: bool = False,
api_base: Optional[str] = None,
**kwargs,
):
"""
Args:
api_key: API key for authentication (defaults to CUA_API_KEY environment variable)
verbose: Enable verbose logging
api_base: Optional API base URL override
"""
# Fall back to environment variable if api_key not provided
if api_key is None:
api_key = os.getenv("CUA_API_KEY")
assert (
api_key
), "api_key required for CloudV2Provider (provide via parameter or CUA_API_KEY environment variable)"
self.api_key = api_key
self.verbose = verbose
self.api_base = (api_base or DEFAULT_API_BASE).rstrip("/")
def _base_headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
**cua_version_headers(),
}
@property
def provider_type(self) -> VMProviderType:
return VMProviderType.CLOUDV2
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
def _get_api_host(self, name: str) -> str:
"""Get the API host for a VM.
Args:
name: VM name
Returns:
API host string in format {name}-api.cua.sh
"""
return f"{name}-api.cua.sh"
def _get_vnc_host(self, name: str) -> str:
"""Get the VNC host for a VM.
Args:
name: VM name
Returns:
VNC host string in format {name}-vnc.cua.sh
"""
return f"{name}-vnc.cua.sh"
async def get_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Get VM information via the public API and optionally probe for os_type.
Uses GET /v1/vms/:name as source of truth for VM existence and status,
then probes the computer-server for supplementary info (os_type).
"""
api_host = self._get_api_host(name)
api_url = f"https://{api_host}:443"
# Query the API for authoritative VM info
url = f"{self.api_base}/v1/vms/{name}"
headers = self._base_headers()
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp:
if resp.status == 404:
return {"name": name, "status": "not_found", "api_url": api_url}
if resp.status == 401:
return {"name": name, "status": "unauthorized", "api_url": api_url}
if resp.status != 200:
text = await resp.text()
logger.error(f"get_vm API error: HTTP {resp.status} - {text}")
return {"name": name, "status": "unknown", "api_url": api_url}
vm_info = await resp.json(content_type=None)
except Exception as e:
logger.error(f"get_vm API request failed: {e}")
return {"name": name, "status": "unknown", "api_url": api_url}
# Enrich with V2 domain URLs
vm_info["api_url"] = api_url
vnc_host = self._get_vnc_host(name)
password = vm_info.get("password")
if not vm_info.get("vnc_url") and isinstance(password, str) and password:
vm_info["vnc_url"] = (
f"https://{vnc_host}:443/vnc.html?autoconnect=true&password={password}"
)
# Map "os" from API to "os_type" for backward compatibility
if vm_info.get("os") and not vm_info.get("os_type"):
vm_info["os_type"] = vm_info["os"]
return vm_info
async def list_vms(self) -> ListVMsResponse:
url = f"{self.api_base}/v1/vms"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp:
if resp.status == 200:
try:
data = await resp.json(content_type=None)
except Exception:
text = await resp.text()
logger.error(f"Failed to parse list_vms JSON: {text}")
return []
if isinstance(data, list):
# Enrich with convenience URLs using new domain format
enriched: List[Dict[str, Any]] = []
for item in data:
vm = dict(item) if isinstance(item, dict) else {}
name = vm.get("name")
password = vm.get("password")
if isinstance(name, str) and name:
api_host = self._get_api_host(name)
vnc_host = self._get_vnc_host(name)
# api_url: always set if missing
if not vm.get("api_url"):
vm["api_url"] = f"https://{api_host}:443"
# vnc_url: only when password available
if not vm.get("vnc_url") and isinstance(password, str) and password:
vm["vnc_url"] = (
f"https://{vnc_host}:443/vnc.html?autoconnect=true&password={password}"
)
enriched.append(vm)
return enriched # type: ignore[return-value]
logger.warning("Unexpected response for list_vms; expected list")
return []
elif resp.status == 401:
logger.error("Unauthorized: invalid Cua API key for list_vms")
return []
else:
text = await resp.text()
logger.error(f"list_vms failed: HTTP {resp.status} - {text}")
return []
async def run_vm(
self,
name: str,
image: Optional[str] = None,
run_opts: Optional[Dict[str, Any]] = None,
storage: Optional[str] = None,
) -> Dict[str, Any]:
"""Start a VM via public API. Returns a minimal status."""
url = f"{self.api_base}/v1/vms/{name}/start"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
if resp.status in (200, 201, 202, 204):
return {"name": name, "status": "starting"}
elif resp.status == 404:
return {"name": name, "status": "not_found"}
elif resp.status == 401:
return {"name": name, "status": "unauthorized"}
else:
text = await resp.text()
return {"name": name, "status": "error", "message": text}
async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Stop a VM via public API."""
url = f"{self.api_base}/v1/vms/{name}/stop"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
if resp.status in (200, 202):
body_status: Optional[str] = None
try:
data = await resp.json(content_type=None)
body_status = data.get("status") if isinstance(data, dict) else None
except Exception:
body_status = None
return {"name": name, "status": body_status or "stopping"}
elif resp.status == 404:
return {"name": name, "status": "not_found"}
elif resp.status == 401:
return {"name": name, "status": "unauthorized"}
else:
text = await resp.text()
return {"name": name, "status": "error", "message": text}
async def restart_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Restart a VM via public API."""
url = f"{self.api_base}/v1/vms/{name}/restart"
headers = self._base_headers()
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers) as resp:
if resp.status in (200, 202):
body_status: Optional[str] = None
try:
data = await resp.json(content_type=None)
body_status = data.get("status") if isinstance(data, dict) else None
except Exception:
body_status = None
return {"name": name, "status": body_status or "restarting"}
elif resp.status == 404:
return {"name": name, "status": "not_found"}
elif resp.status == 401:
return {"name": name, "status": "unauthorized"}
else:
text = await resp.text()
return {"name": name, "status": "error", "message": text}
async def update_vm(
self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None
) -> Dict[str, Any]:
logger.warning("CloudV2Provider.update_vm is not implemented via public API")
return {
"name": name,
"status": "unchanged",
"message": "update_vm not supported by public API",
}
async def get_ip(
self, name: Optional[str] = None, storage: Optional[str] = None, retry_delay: int = 2
) -> str:
"""
Return the VM's API host address using the new domain format.
"""
if name is None:
raise ValueError("VM name is required for CloudV2Provider.get_ip")
return self._get_api_host(name)
@@ -0,0 +1,14 @@
"""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"]
@@ -0,0 +1,611 @@
"""
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 asyncio
import json
import logging
import re
import subprocess
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
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,
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,
api_port: Optional[int] = None,
):
"""Initialize the Docker VM Provider.
Args:
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")
Supported images:
- "trycua/cua-ubuntu:latest" (Kasm-based)
- "trycua/cua-xfce:latest" (vanilla XFCE)
- "trycua/cua-qemu-linux:latest" (QEMU-based, only supports Ubuntu for now)
- "trycua/cua-qemu-windows:latest" (QEMU-based, only supports Windows 11 Enterprise for now)
- "trycua/cua-qemu-android:latest" (QEMU-based Android 11 emulator)
verbose: Enable verbose logging
ephemeral: Use ephemeral (temporary) storage
vnc_port: Port for VNC interface (default: 6901)
api_port: Port for API server (default: 8000)
"""
self.host = host
self.api_port = api_port if api_port is not None else 8000
self.vnc_port = vnc_port
self.ephemeral = ephemeral
self.shared_path = shared_path
self.image = image
self.verbose = verbose
self._container_id = None
self._running_containers = {} # Track running containers by name
# Detect image type and configure user directory accordingly
self._detect_image_config()
# QEMU Linux/Windows images require golden image storage
if self._image_type in ("qemu-linux", "qemu-windows"):
if not storage or Path(storage).is_dir() is False:
raise ValueError(
"Golden image storage path must be provided for QEMU-based Linux/Windows images."
)
self.storage = storage
elif ephemeral:
self.storage = "ephemeral" # Handle ephemeral storage (temporary directory)
else:
self.storage = storage
def _detect_image_config(self):
"""Detect image type and configure paths accordingly."""
# Detect if this is a XFCE, Kasm, or QEMU-based image
if "docker-xfce" in self.image.lower() or "xfce" in self.image.lower():
self._home_dir = "/home/cua"
self._image_type = "docker-xfce"
logger.info(f"Detected docker-xfce image: using {self._home_dir}")
elif "qemu-linux" in self.image.lower():
# QEMU-based Linux images
self._home_dir = ""
self._image_type = "qemu-linux"
logger.info("Detected QEMU Linux image: using /")
elif "qemu-windows" in self.image.lower():
# QEMU-based Windows images
self._home_dir = ""
self._image_type = "qemu-windows"
logger.info("Detected QEMU Windows image: using /")
elif "qemu-android" in self.image.lower():
# QEMU-based Android images
self._home_dir = "/home/androidusr"
self._image_type = "qemu-android"
logger.info("Detected QEMU Android image: using /home/androidusr")
else:
# Default to Kasm configuration
self._home_dir = "/home/kasm-user"
self._image_type = "kasm"
logger.info(f"Detected Kasm image: using {self._home_dir}")
@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"]:
if self.ephemeral:
# Delete existing container
logger.info(f"Deleting existing container {name}")
delete_cmd = ["docker", "rm", name]
result = subprocess.run(delete_cmd, capture_output=True, text=True, check=True)
else:
# 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 devices if specified (e.g., /dev/kvm for QEMU and Android)
has_kvm = False
if "devices" in run_opts:
devices = run_opts["devices"]
has_kvm = "/dev/kvm" in devices
for device in devices:
cmd.extend(["--device", device])
elif "device" in run_opts:
has_kvm = run_opts["device"] == "/dev/kvm"
cmd.extend(["--device", run_opts["device"]])
# QEMU Android requires /dev/kvm
if self._image_type == "qemu-android" and not has_kvm:
raise ValueError(
"Android images requires KVM. "
'Add run_opts={"devices": ["/dev/kvm"]} to your Computer configuration.'
)
if self._image_type in ("qemu-linux", "qemu-windows") and not has_kvm:
logger.warning(
"/dev/kvm device is recommended for QEMU images for better performance."
)
# Add NET_ADMIN capability for QEMU Linux/Windows images
if self._image_type in ("qemu-linux", "qemu-windows"):
cmd.extend(["--cap-add", "NET_ADMIN"])
# Add port mappings
vnc_port = run_opts.get("vnc_port", self.vnc_port)
api_port = run_opts.get("api_port", self.api_port)
# Port mappings differ by image type
if self._image_type in ("qemu-linux", "qemu-windows"):
internal_vnc_port = 8006
internal_api_port = 5000
elif self._image_type == "qemu-android":
internal_vnc_port = 6080
internal_api_port = 8000
else:
# Kasm/XFCE images
internal_vnc_port = 6901
internal_api_port = 8000
if vnc_port:
cmd.extend(["-p", f"{vnc_port}:{internal_vnc_port}"]) # VNC port
if api_port:
cmd.extend(["-p", f"{api_port}:{internal_api_port}"]) # 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 using detected home directory
cmd.extend(["-v", f"{storage_path}:{self._home_dir}/storage"])
# Add shared path if specified
if self.shared_path:
# Mount shared directory using detected home directory
cmd.extend(["-v", f"{self.shared_path}:{self._home_dir}/shared"])
# Add environment variables
cmd.extend(["-e", "VNC_PW=password"]) # Set VNC password
cmd.extend(["-e", "VNCOPTIONS=-disableBasicAuth"]) # Disable VNC basic auth
# Add Android-specific default environment variables
if self._image_type == "qemu-android":
if "env" not in run_opts:
run_opts["env"] = {}
run_opts["env"].setdefault("EMULATOR_DEVICE", "Samsung Galaxy S10")
run_opts["env"]["WEB_VNC"] = "true"
# Add custom environment variables from run_opts
if "env" in run_opts and isinstance(run_opts["env"], dict):
for key, value in run_opts["env"].items():
cmd.extend(["-e", f"{key}={value}"])
# Apply display resolution if provided (e.g., "1024x768")
display_resolution = run_opts.get("display")
if (
isinstance(display_resolution, dict)
and "width" in display_resolution
and "height" in display_resolution
):
cmd.extend(
[
"-e",
f"VNC_RESOLUTION={display_resolution['width']}x{display_resolution['height']}",
]
)
# 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")
# Delete container if ephemeral=True
if self.ephemeral:
cmd = ["docker", "rm", name]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
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 restart_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
raise NotImplementedError("DockerProvider does not support restarting VMs.")
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("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
@@ -0,0 +1,181 @@
"""Factory for creating VM providers."""
import logging
from typing import Any, Dict, Optional, Type, Union
from .base import BaseVMProvider, VMProviderType
logger = logging.getLogger(__name__)
class VMProviderFactory:
"""Factory for creating VM providers based on provider type."""
@staticmethod
def create_provider(
provider_type: Union[str, VMProviderType],
provider_port: int = 7777,
host: str = "localhost",
bin_path: Optional[str] = None,
storage: Optional[str] = None,
shared_path: Optional[str] = None,
image: Optional[str] = None,
verbose: bool = False,
ephemeral: bool = False,
noVNC_port: Optional[int] = None,
api_port: Optional[int] = None,
**kwargs,
) -> BaseVMProvider:
"""Create a VM provider of the specified type.
Args:
provider_type: Type of VM provider to create
provider_port: Port for the provider's API server
host: Hostname for the API server
bin_path: Path to provider binary if needed
storage: Path for persistent VM storage
shared_path: Path for shared folder between host and VM
image: VM image to use (for Lumier provider)
verbose: Enable verbose logging
ephemeral: Use ephemeral (temporary) storage
noVNC_port: Specific port for noVNC interface (for Lumier and Docker provider)
api_port: Specific port for Computer API server (for Docker provider)
Returns:
An instance of the requested VM provider
Raises:
ImportError: If the required dependencies for the provider are not installed
ValueError: If the provider type is not supported
"""
# Convert string to enum if needed
if isinstance(provider_type, str):
try:
provider_type = VMProviderType(provider_type.lower())
except ValueError:
provider_type = VMProviderType.UNKNOWN
if provider_type == VMProviderType.LUME:
try:
from .lume import HAS_LUME, LumeProvider
if not HAS_LUME:
raise ImportError(
"LumeProvider requires curl and the Lume CLI. "
"Please install Lume from https://github.com/trycua/cua/tree/main/libs/lume"
)
return LumeProvider(
provider_port=provider_port,
host=host,
storage=storage,
verbose=verbose,
ephemeral=ephemeral,
)
except ImportError as e:
logger.error(f"Failed to import LumeProvider: {e}")
raise ImportError(
"LumeProvider requires curl and the Lume CLI. "
"Please install Lume from https://github.com/trycua/cua/tree/main/libs/lume"
) from e
elif provider_type == VMProviderType.LUMIER:
try:
from .lumier import HAS_LUMIER, LumierProvider
if not HAS_LUMIER:
raise ImportError(
"Docker is required for LumierProvider. "
"Please install Docker for Apple Silicon and Lume CLI before using this provider."
)
return LumierProvider(
provider_port=provider_port,
host=host,
storage=storage,
shared_path=shared_path,
image=image or "macos-sequoia-cua:latest",
verbose=verbose,
ephemeral=ephemeral,
noVNC_port=noVNC_port,
)
except ImportError as e:
logger.error(f"Failed to import LumierProvider: {e}")
raise ImportError(
"Docker and Lume CLI are required for LumierProvider. "
"Please install Docker for Apple Silicon and run the Lume installer script."
) from e
elif provider_type == VMProviderType.CLOUD:
try:
from .cloud import CloudProvider
return CloudProvider(
verbose=verbose,
**kwargs,
)
except ImportError as e:
logger.error(f"Failed to import CloudProvider: {e}")
raise ImportError(
"The CloudProvider is not fully implemented yet. "
"Please use LUME or LUMIER provider instead."
) from e
elif provider_type == VMProviderType.CLOUDV2:
try:
from .cloud import CloudV2Provider
return CloudV2Provider(
verbose=verbose,
**kwargs,
)
except ImportError as e:
logger.error(f"Failed to import CloudV2Provider: {e}")
raise ImportError(
"CloudV2Provider requires aiohttp. " "Please install with: pip install aiohttp"
) from e
elif provider_type == VMProviderType.WINSANDBOX:
try:
from .winsandbox import HAS_WINSANDBOX, WinSandboxProvider
if not HAS_WINSANDBOX:
raise ImportError(
"pywinsandbox is required for WinSandboxProvider. "
"Please install it with 'pip install -U git+https://github.com/karkason/pywinsandbox.git'"
)
return WinSandboxProvider(
host=host,
storage=storage,
verbose=verbose,
ephemeral=ephemeral,
**kwargs,
)
except ImportError as e:
logger.error(f"Failed to import WinSandboxProvider: {e}")
raise ImportError(
"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 HAS_DOCKER, DockerProvider
if not HAS_DOCKER:
raise ImportError(
"Docker is required for DockerProvider. "
"Please install Docker and ensure it is running."
)
return DockerProvider(
host=host,
storage=storage,
shared_path=shared_path,
image=image or "trycua/cua-ubuntu:latest",
verbose=verbose,
ephemeral=ephemeral,
vnc_port=noVNC_port,
api_port=api_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}")
@@ -0,0 +1,10 @@
"""Lume VM provider implementation."""
try:
from .provider import LumeProvider
HAS_LUME = True
__all__ = ["LumeProvider"]
except ImportError:
HAS_LUME = False
__all__ = []
@@ -0,0 +1,546 @@
"""Lume VM provider implementation using curl commands.
This provider uses direct curl commands to interact with the Lume API.
"""
import asyncio
import json
import logging
import os
import re
import subprocess
import urllib.parse
from typing import Any, Dict, List, Optional, Tuple
from ...logger import Logger, LogLevel
from ..base import BaseVMProvider, VMProviderType
from ..lume_api import (
HAS_CURL,
lume_api_get,
lume_api_pull,
lume_api_run,
lume_api_stop,
lume_api_update,
parse_memory,
)
# Setup logging
logger = logging.getLogger(__name__)
class LumeProvider(BaseVMProvider):
"""Lume VM provider implementation using direct curl commands.
This provider uses curl to interact with the Lume API server.
"""
def __init__(
self,
provider_port: int = 7777,
host: str = "localhost",
storage: Optional[str] = None,
verbose: bool = False,
ephemeral: bool = False,
):
"""Initialize the Lume provider.
Args:
provider_port: Port for the Lume API server (default: 7777)
host: Host to use for API connections (default: localhost)
storage: Path to store VM data
verbose: Enable verbose logging
"""
if not HAS_CURL:
raise ImportError(
"curl is required for LumeProvider. "
"Please ensure it is installed and in your PATH."
)
self.host = host
self.port = provider_port # Default port for Lume API
self.storage = storage
self.verbose = verbose
self.ephemeral = ephemeral # If True, VMs will be deleted after stopping
# Base API URL for Lume API calls
self.api_base_url = f"http://{self.host}:{self.port}"
self.logger = logging.getLogger(__name__)
@property
def provider_type(self) -> VMProviderType:
"""Get the provider type."""
return VMProviderType.LUME
async def __aenter__(self):
"""Enter async context manager."""
# No initialization needed, just return self
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exit async context manager."""
# No cleanup needed
pass
def _lume_api_get(
self, vm_name: str = "", storage: Optional[str] = None, debug: bool = False
) -> Dict[str, Any]:
"""Get VM information using shared lume_api function.
Args:
vm_name: Optional name of the VM to get info for.
If empty, lists all VMs.
storage: Optional storage path override. If provided, this will be used instead of self.storage
debug: Whether to show debug output
Returns:
Dictionary with VM status information parsed from JSON response
"""
# Use the shared implementation from lume_api module
return lume_api_get(
vm_name=vm_name,
host=self.host,
port=self.port,
storage=storage if storage is not None else self.storage,
debug=debug,
verbose=self.verbose,
)
def _lume_api_run(
self, vm_name: str, run_opts: Dict[str, Any], debug: bool = False
) -> Dict[str, Any]:
"""Run a VM using shared lume_api function.
Args:
vm_name: Name of the VM to run
run_opts: Dictionary of run options
debug: Whether to show debug output
Returns:
Dictionary with API response or error information
"""
# Use the shared implementation from lume_api module
return lume_api_run(
vm_name=vm_name,
host=self.host,
port=self.port,
run_opts=run_opts,
storage=self.storage,
debug=debug,
verbose=self.verbose,
)
def _lume_api_stop(self, vm_name: str, debug: bool = False) -> Dict[str, Any]:
"""Stop a VM using shared lume_api function.
Args:
vm_name: Name of the VM to stop
debug: Whether to show debug output
Returns:
Dictionary with API response or error information
"""
# Use the shared implementation from lume_api module
return lume_api_stop(
vm_name=vm_name,
host=self.host,
port=self.port,
storage=self.storage,
debug=debug,
verbose=self.verbose,
)
def _lume_api_update(
self, vm_name: str, update_opts: Dict[str, Any], debug: bool = False
) -> Dict[str, Any]:
"""Update VM configuration using shared lume_api function.
Args:
vm_name: Name of the VM to update
update_opts: Dictionary of update options
debug: Whether to show debug output
Returns:
Dictionary with API response or error information
"""
# Use the shared implementation from lume_api module
return lume_api_update(
vm_name=vm_name,
host=self.host,
port=self.port,
update_opts=update_opts,
storage=self.storage,
debug=debug,
verbose=self.verbose,
)
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.
Note:
If storage is not provided, the provider's default storage path will be used.
The storage parameter allows overriding the storage location for this specific call.
"""
if not HAS_CURL:
logger.error("curl is not available. Cannot get VM status.")
return {"name": name, "status": "unavailable", "error": "curl is not available"}
# First try to get detailed VM info from the API
try:
# Query the Lume API for VM status using the provider's storage_path
vm_info = self._lume_api_get(
vm_name=name,
storage=storage if storage is not None else self.storage,
debug=self.verbose,
)
# Check for API errors
if "error" in vm_info:
logger.debug(f"API request error: {vm_info['error']}")
# If we got an error from the API, report the VM as not ready yet
return {
"name": name,
"status": "starting", # VM is still starting - do not attempt to connect yet
"api_status": "error",
"error": vm_info["error"],
}
# Process the VM status information
vm_status = vm_info.get("status", "unknown")
# Check if VM is stopped or not running - don't wait for IP in this case
if vm_status == "stopped":
logger.info(f"VM {name} is in '{vm_status}' state - not waiting for IP address")
# Return the status as-is without waiting for an IP
result = {
"name": name,
"status": vm_status,
**vm_info, # Include all original fields from the API response
}
return result
# Handle field name differences between APIs
# Some APIs use camelCase, others use snake_case
if "vncUrl" in vm_info:
vnc_url = vm_info["vncUrl"]
elif "vnc_url" in vm_info:
vnc_url = vm_info["vnc_url"]
else:
vnc_url = ""
if "ipAddress" in vm_info:
ip_address = vm_info["ipAddress"]
elif "ip_address" in vm_info:
ip_address = vm_info["ip_address"]
else:
# If no IP address is provided and VM is supposed to be running,
# report it as still starting
ip_address = None
logger.info(
f"VM {name} is in '{vm_status}' state but no IP address found - reporting as still starting"
)
logger.info(f"VM {name} status: {vm_status}")
# Return the complete status information
result = {
"name": name,
"status": vm_status if vm_status else "running",
"ip_address": ip_address,
"vnc_url": vnc_url,
"api_status": "ok",
}
# Include all original fields from the API response
if isinstance(vm_info, dict):
for key, value in vm_info.items():
if key not in result: # Don't override our carefully processed fields
result[key] = value
return result
except Exception as e:
logger.error(f"Failed to get VM status: {e}")
# Return a fallback status that indicates the VM is not ready yet
return {
"name": name,
"status": "initializing", # VM is still initializing
"error": f"Failed to get VM status: {str(e)}",
}
async def list_vms(self) -> List[Dict[str, Any]]:
"""List all available VMs."""
result = self._lume_api_get(debug=self.verbose)
# Extract the VMs list from the response
if "vms" in result and isinstance(result["vms"], list):
return result["vms"]
elif "error" in result:
logger.error(f"Error listing VMs: {result['error']}")
return []
else:
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.
If the VM does not exist in the storage location, this will attempt to pull it
from the Lume registry first.
Args:
image: Image name to use when pulling the VM if it doesn't exist
name: Name of the VM to run
run_opts: Dictionary of run options (memory, cpu, etc.)
storage: Optional storage path override. If provided, this will be used
instead of the provider's default storage path.
Returns:
Dictionary with VM run status and information
"""
# First check if VM exists by trying to get its info
vm_info = await self.get_vm(name, storage=storage)
if "error" in vm_info:
# VM doesn't exist, try to pull it
self.logger.info(
f"VM {name} not found, attempting to pull image {image} from registry..."
)
# Call pull_vm with the image parameter
pull_result = await self.pull_vm(name=name, image=image, storage=storage)
# Check if pull was successful
if "error" in pull_result:
self.logger.error(f"Failed to pull VM image: {pull_result['error']}")
return pull_result # Return the error from pull
self.logger.info(f"Successfully pulled VM image {image} as {name}")
# Now run the VM with the given options
self.logger.info(f"Running VM {name} with options: {run_opts}")
from ..lume_api import lume_api_run
return lume_api_run(
vm_name=name,
host=self.host,
port=self.port,
run_opts=run_opts,
storage=storage if storage is not None else self.storage,
debug=self.verbose,
verbose=self.verbose,
)
async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Stop a running VM.
If this provider was initialized with ephemeral=True, the VM will also
be deleted after it is stopped.
Args:
name: Name of the VM to stop
storage: Optional storage path override
Returns:
Dictionary with stop status and information
"""
# Stop the VM first
stop_result = self._lume_api_stop(name, debug=self.verbose)
# Log ephemeral status for debugging
self.logger.info(f"Ephemeral mode status: {self.ephemeral}")
# If ephemeral mode is enabled, delete the VM after stopping
if self.ephemeral and (stop_result.get("success", False) or "error" not in stop_result):
self.logger.info(f"Ephemeral mode enabled - deleting VM {name} after stopping")
try:
delete_result = await self.delete_vm(name, storage=storage)
# Return combined result
return {
**stop_result, # Include all stop result info
"deleted": True,
"delete_result": delete_result,
}
except Exception as e:
self.logger.error(f"Failed to delete ephemeral VM {name}: {e}")
# Include the error but still return stop result
return {**stop_result, "deleted": False, "delete_error": str(e)}
# Just return the stop result if not ephemeral
return stop_result
async def pull_vm(
self,
name: str,
image: str,
storage: Optional[str] = None,
registry: str = "ghcr.io",
organization: str = "trycua",
pull_opts: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Pull a VM image from the registry.
Args:
name: Name for the VM after pulling
image: The image name to pull (e.g. 'macos-sequoia-cua:latest')
storage: Optional storage path to use
registry: Registry to pull from (default: ghcr.io)
organization: Organization in registry (default: trycua)
pull_opts: Additional options for pulling the VM (optional)
Returns:
Dictionary with information about the pulled VM
Raises:
RuntimeError: If pull operation fails or image is not provided
"""
# Validate image parameter
if not image:
raise ValueError("Image parameter is required for pull_vm")
self.logger.info(f"Pulling VM image '{image}' as '{name}'")
self.logger.info("You can check the pull progress using: lume logs -f")
# Set default pull_opts if not provided
if pull_opts is None:
pull_opts = {}
# Log information about the operation
self.logger.debug(f"Pull storage location: {storage or 'default'}")
try:
# Call the lume_api_pull function from lume_api.py
from ..lume_api import lume_api_pull
result = lume_api_pull(
image=image,
name=name,
host=self.host,
port=self.port,
storage=storage if storage is not None else self.storage,
registry=registry,
organization=organization,
debug=self.verbose,
verbose=self.verbose,
)
# Check for errors in the result
if "error" in result:
self.logger.error(f"Failed to pull VM image: {result['error']}")
return result
self.logger.info(f"Successfully pulled VM image '{image}' as '{name}'")
return result
except Exception as e:
self.logger.error(f"Failed to pull VM image '{image}': {e}")
return {"error": f"Failed to pull VM: {str(e)}"}
async def delete_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Delete a VM permanently.
Args:
name: Name of the VM to delete
storage: Optional storage path override
Returns:
Dictionary with delete status and information
"""
self.logger.info(f"Deleting VM {name}...")
try:
# Call the lume_api_delete function we created
from ..lume_api import lume_api_delete
result = lume_api_delete(
vm_name=name,
host=self.host,
port=self.port,
storage=storage if storage is not None else self.storage,
debug=self.verbose,
verbose=self.verbose,
)
# Check for errors in the result
if "error" in result:
self.logger.error(f"Failed to delete VM: {result['error']}")
return result
self.logger.info(f"Successfully deleted VM '{name}'")
return result
except Exception as e:
self.logger.error(f"Failed to delete VM '{name}': {e}")
return {"error": f"Failed to delete VM: {str(e)}"}
async def update_vm(
self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None
) -> Dict[str, Any]:
"""Update VM configuration."""
return self._lume_api_update(name, update_opts, debug=self.verbose)
async def restart_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
raise NotImplementedError("LumeProvider does not support restarting VMs.")
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
"""
# Track total attempts for logging purposes
total_attempts = 0
# Loop indefinitely until we get a valid IP
while True:
total_attempts += 1
# Log retry message but not on first attempt
if total_attempts > 1:
self.logger.info(f"Waiting for VM {name} IP address (attempt {total_attempts})...")
try:
# Get VM information
vm_info = await self.get_vm(name, storage=storage)
# 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"):
self.logger.info(f"Got valid VM IP address: {ip}")
return ip
# Check the VM status
status = vm_info.get("status", "unknown")
# If VM is not running yet, log and wait
if status != "running":
self.logger.info(f"VM is not running yet (status: {status}). Waiting...")
# If VM is running but no IP yet, wait and retry
else:
self.logger.info("VM is running but no valid IP address yet. Waiting...")
except Exception as e:
self.logger.warning(f"Error getting VM {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:
self.logger.info(
f"Still waiting for VM {name} IP after {total_attempts} attempts..."
)
@@ -0,0 +1,624 @@
"""Shared API utilities for Lume and Lumier providers.
This module contains shared functions for interacting with the Lume API,
used by both the LumeProvider and LumierProvider classes.
"""
import json
import logging
import subprocess
import urllib.parse
from typing import Any, Dict, List, Optional
from computer.utils import safe_join
# Setup logging
logger = logging.getLogger(__name__)
# Check if curl is available
try:
subprocess.run(["curl", "--version"], capture_output=True, check=True)
HAS_CURL = True
except (subprocess.SubprocessError, FileNotFoundError):
HAS_CURL = False
def lume_api_get(
vm_name: str,
host: str,
port: int,
storage: Optional[str] = None,
debug: bool = False,
verbose: bool = False,
) -> Dict[str, Any]:
"""Use curl to get VM information from Lume API.
Args:
vm_name: Name of the VM to get info for
host: API host
port: API port
storage: Storage path for the VM
debug: Whether to show debug output
verbose: Enable verbose logging
Returns:
Dictionary with VM status information parsed from JSON response
"""
# URL encode the storage parameter for the query
encoded_storage = ""
storage_param = ""
if storage:
# First encode the storage path properly
encoded_storage = urllib.parse.quote(storage, safe="")
storage_param = f"?storage={encoded_storage}"
# Construct API URL with encoded storage parameter if needed
api_url = f"http://{host}:{port}/lume/vms/{vm_name}{storage_param}"
# Construct the curl command with increased timeouts for more reliability
# --connect-timeout: Time to establish connection (15 seconds)
# --max-time: Maximum time for the whole operation (20 seconds)
# -f: Fail silently (no output at all) on server errors
# Add single quotes around URL to ensure special characters are handled correctly
cmd = ["curl", "--connect-timeout", "15", "--max-time", "20", "-s", "-f", api_url]
# For logging and display, show the properly escaped URL
display_cmd = ["curl", "--connect-timeout", "15", "--max-time", "20", "-s", "-f", api_url]
# Only print the curl command when debug is enabled
display_curl_string = " ".join(display_cmd)
logger.debug(f"Executing API request: {display_curl_string}")
# Execute the command - for execution we need to use shell=True to handle URLs with special characters
try:
# Use a single string with shell=True for proper URL handling
shell_cmd = safe_join(cmd)
result = subprocess.run(shell_cmd, shell=True, capture_output=True, text=True)
# Handle curl exit codes
if result.returncode != 0:
curl_error = "Unknown error"
# Map common curl error codes to helpful messages
if result.returncode == 7:
curl_error = "Failed to connect to the API server - it might still be starting up"
elif result.returncode == 22:
curl_error = "HTTP error returned from API server"
elif result.returncode == 28:
curl_error = "Operation timeout - the API server is taking too long to respond"
elif result.returncode == 52:
curl_error = (
"Empty reply from server - the API server is starting but not fully ready yet"
)
elif result.returncode == 56:
curl_error = "Network problem during data transfer - check container networking"
# Only log at debug level to reduce noise during retries
logger.debug(f"API request failed with code {result.returncode}: {curl_error}")
# Return a more useful error message
return {
"error": f"API request failed: {curl_error}",
"curl_code": result.returncode,
"vm_name": vm_name,
"status": "unknown", # We don't know the actual status due to API error
}
# Try to parse the response as JSON
if result.stdout and result.stdout.strip():
try:
vm_status = json.loads(result.stdout)
if debug or verbose:
logger.info(
f"Successfully parsed VM status: {vm_status.get('status', 'unknown')}"
)
return vm_status
except json.JSONDecodeError as e:
# Return the raw response if it's not valid JSON
logger.warning(f"Invalid JSON response: {e}")
if "Virtual machine not found" in result.stdout:
return {"status": "not_found", "message": "VM not found in Lume API"}
return {
"error": f"Invalid JSON response: {result.stdout[:100]}...",
"status": "unknown",
}
else:
return {"error": "Empty response from API", "status": "unknown"}
except subprocess.SubprocessError as e:
logger.error(f"Failed to execute API request: {e}")
return {"error": f"Failed to execute API request: {str(e)}", "status": "unknown"}
def lume_api_run(
vm_name: str,
host: str,
port: int,
run_opts: Dict[str, Any],
storage: Optional[str] = None,
debug: bool = False,
verbose: bool = False,
) -> Dict[str, Any]:
"""Run a VM using curl.
Args:
vm_name: Name of the VM to run
host: API host
port: API port
run_opts: Dictionary of run options
storage: Storage path for the VM
debug: Whether to show debug output
verbose: Enable verbose logging
Returns:
Dictionary with API response or error information
"""
# Construct API URL
api_url = f"http://{host}:{port}/lume/vms/{vm_name}/run"
# Prepare JSON payload with required parameters
payload = {}
# Add CPU cores if specified
if "cpu" in run_opts:
payload["cpu"] = run_opts["cpu"]
# Add memory if specified
if "memory" in run_opts:
payload["memory"] = run_opts["memory"]
# Add storage parameter if specified
if storage:
payload["storage"] = storage
elif "storage" in run_opts:
payload["storage"] = run_opts["storage"]
# Add shared directories if specified
if "shared_directories" in run_opts and run_opts["shared_directories"]:
payload["sharedDirectories"] = run_opts["shared_directories"]
# Log the payload for debugging
logger.debug(f"API payload: {json.dumps(payload, indent=2)}")
# Construct the curl command
cmd = [
"curl",
"--connect-timeout",
"30",
"--max-time",
"30",
"-s",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
json.dumps(payload),
api_url,
]
# Execute the command
try:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.warning(f"API request failed with code {result.returncode}: {result.stderr}")
return {"error": f"API request failed: {result.stderr}"}
# Try to parse the response as JSON
if result.stdout and result.stdout.strip():
try:
response = json.loads(result.stdout)
return response
except json.JSONDecodeError:
# Return the raw response if it's not valid JSON
return {
"success": True,
"message": "VM started successfully",
"raw_response": result.stdout,
}
else:
return {"success": True, "message": "VM started successfully"}
except subprocess.SubprocessError as e:
logger.error(f"Failed to execute run request: {e}")
return {"error": f"Failed to execute run request: {str(e)}"}
def lume_api_stop(
vm_name: str,
host: str,
port: int,
storage: Optional[str] = None,
debug: bool = False,
verbose: bool = False,
) -> Dict[str, Any]:
"""Stop a VM using curl.
Args:
vm_name: Name of the VM to stop
host: API host
port: API port
storage: Storage path for the VM
debug: Whether to show debug output
verbose: Enable verbose logging
Returns:
Dictionary with API response or error information
"""
# Construct API URL
api_url = f"http://{host}:{port}/lume/vms/{vm_name}/stop"
# Prepare JSON payload with required parameters
payload = {}
# Add storage path if specified
if storage:
payload["storage"] = storage
# Construct the curl command
cmd = [
"curl",
"--connect-timeout",
"15",
"--max-time",
"20",
"-s",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
json.dumps(payload),
api_url,
]
# Execute the command
try:
if debug or verbose:
logger.info(f"Executing: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.warning(f"API request failed with code {result.returncode}: {result.stderr}")
return {"error": f"API request failed: {result.stderr}"}
# Try to parse the response as JSON
if result.stdout and result.stdout.strip():
try:
response = json.loads(result.stdout)
return response
except json.JSONDecodeError:
# Return the raw response if it's not valid JSON
return {
"success": True,
"message": "VM stopped successfully",
"raw_response": result.stdout,
}
else:
return {"success": True, "message": "VM stopped successfully"}
except subprocess.SubprocessError as e:
logger.error(f"Failed to execute stop request: {e}")
return {"error": f"Failed to execute stop request: {str(e)}"}
def lume_api_update(
vm_name: str,
host: str,
port: int,
update_opts: Dict[str, Any],
storage: Optional[str] = None,
debug: bool = False,
verbose: bool = False,
) -> Dict[str, Any]:
"""Update VM settings using curl.
Args:
vm_name: Name of the VM to update
host: API host
port: API port
update_opts: Dictionary of update options
storage: Storage path for the VM
debug: Whether to show debug output
verbose: Enable verbose logging
Returns:
Dictionary with API response or error information
"""
# Construct API URL
api_url = f"http://{host}:{port}/lume/vms/{vm_name}/update"
# Prepare JSON payload with required parameters
payload = {}
# Add CPU cores if specified
if "cpu" in update_opts:
payload["cpu"] = update_opts["cpu"]
# Add memory if specified
if "memory" in update_opts:
payload["memory"] = update_opts["memory"]
# Add storage path if specified
if storage:
payload["storage"] = storage
# Construct the curl command
cmd = [
"curl",
"--connect-timeout",
"15",
"--max-time",
"20",
"-s",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
json.dumps(payload),
api_url,
]
# Execute the command
try:
if debug:
logger.info(f"Executing: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.warning(f"API request failed with code {result.returncode}: {result.stderr}")
return {"error": f"API request failed: {result.stderr}"}
# Try to parse the response as JSON
if result.stdout and result.stdout.strip():
try:
response = json.loads(result.stdout)
return response
except json.JSONDecodeError:
# Return the raw response if it's not valid JSON
return {
"success": True,
"message": "VM updated successfully",
"raw_response": result.stdout,
}
else:
return {"success": True, "message": "VM updated successfully"}
except subprocess.SubprocessError as e:
logger.error(f"Failed to execute update request: {e}")
return {"error": f"Failed to execute update request: {str(e)}"}
def lume_api_pull(
image: str,
name: str,
host: str,
port: int,
storage: Optional[str] = None,
registry: str = "ghcr.io",
organization: str = "trycua",
debug: bool = False,
verbose: bool = False,
) -> Dict[str, Any]:
"""Pull a VM image from a registry using curl.
Args:
image: Name/tag of the image to pull
name: Name to give the VM after pulling
host: API host
port: API port
storage: Storage path for the VM
registry: Registry to pull from (default: ghcr.io)
organization: Organization in registry (default: trycua)
debug: Whether to show debug output
verbose: Enable verbose logging
Returns:
Dictionary with pull status and information
"""
# Prepare pull request payload
pull_payload = {
"image": image, # Use provided image name
"name": name, # Always use name as the target VM name
"registry": registry,
"organization": organization,
}
if storage:
pull_payload["storage"] = storage
# Construct pull command with proper JSON payload
pull_cmd = ["curl"]
if not verbose:
pull_cmd.append("-s")
pull_cmd.extend(
[
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
json.dumps(pull_payload),
f"http://{host}:{port}/lume/pull",
]
)
logger.debug(f"Executing API request: {' '.join(pull_cmd)}")
try:
# Execute pull command
result = subprocess.run(pull_cmd, capture_output=True, text=True)
if result.returncode != 0:
error_msg = f"Failed to pull VM {name}: {result.stderr}"
logger.error(error_msg)
return {"error": error_msg}
try:
response = json.loads(result.stdout)
logger.info(f"Successfully initiated pull for VM {name}")
return response
except json.JSONDecodeError:
if result.stdout:
logger.info(f"Pull response: {result.stdout}")
return {"success": True, "message": f"Successfully initiated pull for VM {name}"}
except subprocess.SubprocessError as e:
error_msg = f"Failed to execute pull command: {str(e)}"
logger.error(error_msg)
return {"error": error_msg}
def lume_api_delete(
vm_name: str,
host: str,
port: int,
storage: Optional[str] = None,
debug: bool = False,
verbose: bool = False,
) -> Dict[str, Any]:
"""Delete a VM using curl.
Args:
vm_name: Name of the VM to delete
host: API host
port: API port
storage: Storage path for the VM
debug: Whether to show debug output
verbose: Enable verbose logging
Returns:
Dictionary with API response or error information
"""
# URL encode the storage parameter for the query
encoded_storage = ""
storage_param = ""
if storage:
# First encode the storage path properly
encoded_storage = urllib.parse.quote(storage, safe="")
storage_param = f"?storage={encoded_storage}"
# Construct API URL with encoded storage parameter if needed
api_url = f"http://{host}:{port}/lume/vms/{vm_name}{storage_param}"
# Construct the curl command for DELETE operation - using much longer timeouts matching shell implementation
cmd = [
"curl",
"--connect-timeout",
"6000",
"--max-time",
"5000",
"-s",
"-X",
"DELETE",
api_url,
]
# For logging and display, show the properly escaped URL
display_cmd = [
"curl",
"--connect-timeout",
"6000",
"--max-time",
"5000",
"-s",
"-X",
"DELETE",
api_url,
]
# Only print the curl command when debug is enabled
display_curl_string = " ".join(display_cmd)
logger.debug(f"Executing API request: {display_curl_string}")
# Execute the command - for execution we need to use shell=True to handle URLs with special characters
try:
# Use a single string with shell=True for proper URL handling
shell_cmd = safe_join(cmd)
result = subprocess.run(shell_cmd, shell=True, capture_output=True, text=True)
# Handle curl exit codes
if result.returncode != 0:
curl_error = "Unknown error"
# Map common curl error codes to helpful messages
if result.returncode == 7:
curl_error = "Failed to connect to the API server - it might still be starting up"
elif result.returncode == 22:
curl_error = "HTTP error returned from API server"
elif result.returncode == 28:
curl_error = "Operation timeout - the API server is taking too long to respond"
elif result.returncode == 52:
curl_error = (
"Empty reply from server - the API server is starting but not fully ready yet"
)
elif result.returncode == 56:
curl_error = "Network problem during data transfer - check container networking"
# Only log at debug level to reduce noise during retries
logger.debug(f"API request failed with code {result.returncode}: {curl_error}")
# Return a more useful error message
return {
"error": f"API request failed: {curl_error}",
"curl_code": result.returncode,
"vm_name": vm_name,
"storage": storage,
}
# Try to parse the response as JSON
if result.stdout and result.stdout.strip():
try:
response = json.loads(result.stdout)
return response
except json.JSONDecodeError:
# Return the raw response if it's not valid JSON
return {
"success": True,
"message": "VM deleted successfully",
"raw_response": result.stdout,
}
else:
return {"success": True, "message": "VM deleted successfully"}
except subprocess.SubprocessError as e:
logger.error(f"Failed to execute delete request: {e}")
return {"error": f"Failed to execute delete request: {str(e)}"}
def parse_memory(memory_str: str) -> int:
"""Parse memory string to MB integer.
Examples:
"8GB" -> 8192
"1024MB" -> 1024
"512" -> 512
Returns:
Memory value in MB
"""
if isinstance(memory_str, int):
return memory_str
if isinstance(memory_str, str):
# Extract number and unit
import re
match = re.match(r"(\d+)([A-Za-z]*)", memory_str)
if match:
value, unit = match.groups()
value = int(value)
unit = unit.upper()
if unit == "GB" or unit == "G":
return value * 1024
elif unit == "MB" or unit == "M" or unit == "":
return value
# Default fallback
logger.warning(f"Could not parse memory string '{memory_str}', using 8GB default")
return 8192 # Default to 8GB
@@ -0,0 +1,9 @@
"""Lumier VM provider implementation."""
try:
# Use the same import approach as in the Lume provider
from .provider import LumierProvider
HAS_LUMIER = True
except ImportError:
HAS_LUMIER = False
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
"""Shared provider type definitions for VM metadata and responses.
These base types describe the common shape of objects returned by provider
methods like `list_vms()`.
"""
from __future__ import annotations
from typing import Literal, NotRequired, TypedDict
# Core status values per product docs
VMStatus = Literal[
"pending", # VM deployment in progress
"running", # VM is active and accessible
"stopped", # VM is stopped but not terminated
"terminated", # VM has been permanently destroyed
"failed", # VM deployment or operation failed
]
OSType = Literal["macos", "linux", "windows"]
class MinimalVM(TypedDict):
"""Minimal VM object shape returned by list calls.
Providers may include additional fields. Optional fields below are
common extensions some providers expose or that callers may compute.
"""
name: str
status: VMStatus
# Not always included by all providers
password: NotRequired[str]
vnc_url: NotRequired[str]
api_url: NotRequired[str]
# Convenience alias for list_vms() responses
ListVMsResponse = list[MinimalVM]
@@ -0,0 +1,12 @@
"""Windows Sandbox provider for Cua Computer."""
try:
import winsandbox
HAS_WINSANDBOX = True
except ImportError:
HAS_WINSANDBOX = False
from .provider import WinSandboxProvider
__all__ = ["WinSandboxProvider", "HAS_WINSANDBOX"]
@@ -0,0 +1,515 @@
"""Windows Sandbox VM provider implementation using pywinsandbox."""
import asyncio
import logging
import os
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..base import BaseVMProvider, VMProviderType
# Setup logging
logger = logging.getLogger(__name__)
try:
import winsandbox
HAS_WINSANDBOX = True
except ImportError:
HAS_WINSANDBOX = False
class WinSandboxProvider(BaseVMProvider):
"""Windows Sandbox VM provider implementation using pywinsandbox.
This provider uses Windows Sandbox to create isolated Windows environments.
Storage is always ephemeral with Windows Sandbox.
"""
def __init__(
self,
host: str = "localhost",
storage: Optional[str] = None,
verbose: bool = False,
ephemeral: bool = True, # Windows Sandbox is always ephemeral
memory_mb: int = 4096,
networking: bool = True,
**kwargs,
):
"""Initialize the Windows Sandbox provider.
Args:
host: Host to use for connections (default: localhost)
storage: Storage path (ignored - Windows Sandbox is always ephemeral)
verbose: Enable verbose logging
ephemeral: Always True for Windows Sandbox
memory_mb: Memory allocation in MB (default: 4096)
networking: Enable networking in sandbox (default: True)
"""
if not HAS_WINSANDBOX:
raise ImportError(
"pywinsandbox is required for WinSandboxProvider. "
"Please install it with 'pip install pywinsandbox'"
)
self.host = host
self.verbose = verbose
self.memory_mb = memory_mb
self.networking = networking
# Windows Sandbox is always ephemeral
if not ephemeral:
logger.warning("Windows Sandbox storage is always ephemeral. Ignoring ephemeral=False.")
self.ephemeral = True
# Storage is always ephemeral for Windows Sandbox
if storage and storage != "ephemeral":
logger.warning(
"Windows Sandbox does not support persistent storage. Using ephemeral storage."
)
self.storage = "ephemeral"
self.logger = logging.getLogger(__name__)
# Track active sandboxes
self._active_sandboxes: Dict[str, Any] = {}
@property
def provider_type(self) -> VMProviderType:
"""Get the provider type."""
return VMProviderType.WINSANDBOX
async def __aenter__(self):
"""Enter async context manager."""
# Verify Windows Sandbox is available
if not HAS_WINSANDBOX:
raise ImportError("pywinsandbox is not available")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Exit async context manager."""
# Clean up any active sandboxes
for name, sandbox in self._active_sandboxes.items():
try:
sandbox.shutdown()
self.logger.info(f"Terminated sandbox: {name}")
except Exception as e:
self.logger.error(f"Error terminating sandbox {name}: {e}")
self._active_sandboxes.clear()
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: Ignored for Windows Sandbox (always ephemeral)
Returns:
Dictionary with VM information including status, IP address, etc.
"""
if name not in self._active_sandboxes:
return {"name": name, "status": "stopped", "ip_address": None, "storage": "ephemeral"}
sandbox = self._active_sandboxes[name]
# Check if sandbox is still running
try:
# Try to ping the sandbox to see if it's responsive
try:
sandbox.rpyc.modules.os.getcwd()
sandbox_responsive = True
except Exception:
sandbox_responsive = False
if not sandbox_responsive:
return {
"name": name,
"status": "starting",
"ip_address": None,
"storage": "ephemeral",
"memory_mb": self.memory_mb,
"networking": self.networking,
}
# Check for computer server address file
server_address_file = (
r"C:\Users\WDAGUtilityAccount\Desktop\shared_windows_sandbox_dir\server_address"
)
try:
# Check if the server address file exists
file_exists = sandbox.rpyc.modules.os.path.exists(server_address_file)
if file_exists:
# Read the server address file
with sandbox.rpyc.builtin.open(server_address_file, "r") as f:
server_address = f.read().strip()
if server_address and ":" in server_address:
# Parse IP:port from the file
ip_address, port = server_address.split(":", 1)
# Verify the server is actually responding
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(3)
result = sock.connect_ex((ip_address, int(port)))
sock.close()
if result == 0:
# Server is responding
status = "running"
self.logger.debug(f"Computer server found at {ip_address}:{port}")
else:
# Server file exists but not responding
status = "starting"
ip_address = None
except Exception as e:
self.logger.debug(f"Error checking server connectivity: {e}")
status = "starting"
ip_address = None
else:
# File exists but doesn't contain valid address
status = "starting"
ip_address = None
else:
# Server address file doesn't exist yet
status = "starting"
ip_address = None
except Exception as e:
self.logger.debug(f"Error checking server address file: {e}")
status = "starting"
ip_address = None
except Exception as e:
self.logger.error(f"Error checking sandbox status: {e}")
status = "error"
ip_address = None
return {
"name": name,
"status": status,
"ip_address": ip_address,
"storage": "ephemeral",
"memory_mb": self.memory_mb,
"networking": self.networking,
}
async def list_vms(self) -> List[Dict[str, Any]]:
"""List all available VMs."""
vms = []
for name in self._active_sandboxes.keys():
vm_info = await self.get_vm(name)
vms.append(vm_info)
return vms
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: Image name (ignored for Windows Sandbox - always uses host Windows)
name: Name of the VM to run
run_opts: Dictionary of run options (memory, cpu, etc.)
storage: Ignored for Windows Sandbox (always ephemeral)
Returns:
Dictionary with VM run status and information
"""
if name in self._active_sandboxes:
return {"success": False, "error": f"Sandbox {name} is already running"}
try:
# Extract options from run_opts
memory_mb = run_opts.get("memory_mb", self.memory_mb)
if isinstance(memory_mb, str):
# Convert memory string like "4GB" to MB
if memory_mb.upper().endswith("GB"):
memory_mb = int(float(memory_mb[:-2]) * 1024)
elif memory_mb.upper().endswith("MB"):
memory_mb = int(memory_mb[:-2])
else:
memory_mb = self.memory_mb
networking = run_opts.get("networking", self.networking)
# Create folder mappers; always map a persistent venv directory on host for caching packages
folder_mappers = []
# Ensure host side persistent venv directory exists (Path.home()/wsb_venv)
host_wsb_env = Path.home() / ".cua" / "wsb_cache"
try:
host_wsb_env.mkdir(parents=True, exist_ok=True)
except Exception:
# If cannot create, continue without persistent mapping
host_wsb_env = None
shared_directories = run_opts.get("shared_directories", [])
for shared_dir in shared_directories:
if isinstance(shared_dir, dict):
host_path = shared_dir.get("hostPath", "")
elif isinstance(shared_dir, str):
host_path = shared_dir
else:
continue
if host_path and os.path.exists(host_path):
folder_mappers.append(winsandbox.FolderMapper(host_path))
# Add mapping for the persistent venv directory (read/write) so it appears in Sandbox Desktop
if host_wsb_env is not None and host_wsb_env.exists():
try:
folder_mappers.append(
winsandbox.FolderMapper(str(host_wsb_env), read_only=False)
)
except Exception as e:
self.logger.warning(f"Failed to map host winsandbox_venv: {e}")
self.logger.info(f"Creating Windows Sandbox: {name}")
self.logger.info(f"Memory: {memory_mb}MB, Networking: {networking}")
if folder_mappers:
self.logger.info(f"Shared directories: {len(folder_mappers)}")
# Create the sandbox without logon script
try:
# Try with memory_mb parameter (newer pywinsandbox version)
sandbox = winsandbox.new_sandbox(
memory_mb=str(memory_mb), networking=networking, folder_mappers=folder_mappers
)
except TypeError as e:
if "memory_mb" in str(e):
# Fallback for older pywinsandbox version that doesn't support memory_mb
self.logger.warning(
"Your pywinsandbox version doesn't support memory_mb parameter. "
"Using default memory settings. To use custom memory settings, "
"please update pywinsandbox: pip install -U git+https://github.com/karkason/pywinsandbox.git"
)
sandbox = winsandbox.new_sandbox(
networking=networking, folder_mappers=folder_mappers
)
else:
# Re-raise if it's a different TypeError
raise
# Store the sandbox
self._active_sandboxes[name] = sandbox
self.logger.info(f"Windows Sandbox {name} created successfully")
venv_exists = (
(host_wsb_env / "venv" / "Lib" / "site-packages" / "computer_server").exists()
if host_wsb_env
else False
)
# Setup the computer server in the sandbox
await self._setup_computer_server(sandbox, name, wait_for_venv=(not venv_exists))
return {
"success": True,
"name": name,
"status": "starting",
"memory_mb": memory_mb,
"networking": networking,
"storage": "ephemeral",
}
except Exception as e:
self.logger.error(f"Failed to create Windows Sandbox {name}: {e}")
# stack trace
import traceback
self.logger.error(f"Stack trace: {traceback.format_exc()}")
return {"success": False, "error": f"Failed to create sandbox: {str(e)}"}
async def stop_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
"""Stop a running VM.
Args:
name: Name of the VM to stop
storage: Ignored for Windows Sandbox
Returns:
Dictionary with stop status and information
"""
if name not in self._active_sandboxes:
return {"success": False, "error": f"Sandbox {name} is not running"}
try:
sandbox = self._active_sandboxes[name]
# Terminate the sandbox
sandbox.shutdown()
# Remove from active sandboxes
del self._active_sandboxes[name]
self.logger.info(f"Windows Sandbox {name} stopped successfully")
return {"success": True, "name": name, "status": "stopped"}
except Exception as e:
self.logger.error(f"Failed to stop Windows Sandbox {name}: {e}")
return {"success": False, "error": f"Failed to stop sandbox: {str(e)}"}
async def update_vm(
self, name: str, update_opts: Dict[str, Any], storage: Optional[str] = None
) -> Dict[str, Any]:
"""Update VM configuration.
Note: Windows Sandbox does not support runtime configuration updates.
The sandbox must be stopped and restarted with new configuration.
Args:
name: Name of the VM to update
update_opts: Dictionary of update options
storage: Ignored for Windows Sandbox
Returns:
Dictionary with update status and information
"""
return {
"success": False,
"error": "Windows Sandbox does not support runtime configuration updates. "
"Please stop and restart the sandbox with new configuration.",
}
async def restart_vm(self, name: str, storage: Optional[str] = None) -> Dict[str, Any]:
raise NotImplementedError("WinSandboxProvider does not support restarting VMs.")
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: Ignored for Windows Sandbox
retry_delay: Delay between retries in seconds (default: 2)
Returns:
IP address of the VM when it becomes available
"""
total_attempts = 0
# Loop indefinitely until we get a valid IP
while True:
total_attempts += 1
# Log retry message but not on first attempt
if total_attempts > 1:
self.logger.info(
f"Waiting for Windows Sandbox {name} IP address (attempt {total_attempts})..."
)
try:
# Get VM information
vm_info = await self.get_vm(name, storage=storage)
# 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"):
self.logger.info(f"Got valid Windows Sandbox IP address: {ip}")
return ip
# Check the VM status
status = vm_info.get("status", "unknown")
# If VM is not running yet, log and wait
if status != "running":
self.logger.info(
f"Windows Sandbox is not running yet (status: {status}). Waiting..."
)
# If VM is running but no IP yet, wait and retry
else:
self.logger.info(
"Windows Sandbox is running but no valid IP address yet. Waiting..."
)
except Exception as e:
self.logger.warning(
f"Error getting Windows Sandbox {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:
self.logger.info(
f"Still waiting for Windows Sandbox {name} IP after {total_attempts} attempts..."
)
async def _setup_computer_server(
self, sandbox, name: str, visible: bool = False, wait_for_venv: bool = True
):
"""Setup the computer server in the Windows Sandbox using RPyC.
Args:
sandbox: The Windows Sandbox instance
name: Name of the sandbox
visible: Whether the opened process should be visible (default: False)
"""
try:
self.logger.info(f"Setting up computer server in sandbox {name}...")
# Read the PowerShell setup script
script_path = os.path.join(os.path.dirname(__file__), "setup_script.ps1")
with open(script_path, "r", encoding="utf-8") as f:
setup_script_content = f.read()
# Write the setup script to the sandbox using RPyC
script_dest_path = r"C:\Users\WDAGUtilityAccount\setup_cua.ps1"
self.logger.info(f"Writing setup script to {script_dest_path}")
with sandbox.rpyc.builtin.open(script_dest_path, "w") as f:
f.write(setup_script_content)
# Execute the PowerShell script in the background
self.logger.info("Executing setup script in sandbox...")
# Use subprocess to run PowerShell script
import subprocess
powershell_cmd = [
"powershell.exe",
"-ExecutionPolicy",
"Bypass",
"-NoExit", # Keep window open after script completes
"-File",
script_dest_path,
]
# Set creation flags based on visibility preference
if visible:
# CREATE_NEW_CONSOLE - creates a new console window (visible)
creation_flags = 0x00000010
else:
creation_flags = 0x08000000 # CREATE_NO_WINDOW
# Start the process using RPyC
process = sandbox.rpyc.modules.subprocess.Popen(
powershell_cmd, creationflags=creation_flags, shell=False
)
if wait_for_venv:
print(
"Waiting for venv to be created for the first time setup of Windows Sandbox..."
)
print("This may take a minute...")
await asyncio.sleep(120)
ip = await self.get_ip(name)
self.logger.info(f"Sandbox IP: {ip}")
self.logger.info(
f"Setup script started in background in sandbox {name} with PID: {process.pid}"
)
except Exception as e:
self.logger.error(f"Failed to setup computer server in sandbox {name}: {e}")
import traceback
self.logger.error(f"Stack trace: {traceback.format_exc()}")
@@ -0,0 +1,153 @@
# Setup script for Windows Sandbox Cua Computer provider
# This script runs when the sandbox starts
Write-Host "Starting Cua Computer setup in Windows Sandbox..."
# Function to find the mapped Python installation from pywinsandbox
function Find-MappedPython {
Write-Host "Looking for mapped Python installation from pywinsandbox..."
# pywinsandbox maps the host Python installation to the sandbox
# Look for mapped shared folders on the desktop (common pywinsandbox pattern)
$desktopPath = "C:\Users\WDAGUtilityAccount\Desktop"
$sharedFolders = Get-ChildItem -Path $desktopPath -Directory -ErrorAction SilentlyContinue
foreach ($folder in $sharedFolders) {
# Look for Python executables in shared folders
$pythonPaths = @(
"$($folder.FullName)\python.exe",
"$($folder.FullName)\Scripts\python.exe",
"$($folder.FullName)\bin\python.exe"
)
foreach ($pythonPath in $pythonPaths) {
if (Test-Path $pythonPath) {
try {
$version = & $pythonPath --version 2>&1
if ($version -match "Python") {
Write-Host "Found mapped Python: $pythonPath - $version"
return $pythonPath
}
} catch {
continue
}
}
}
# Also check subdirectories that might contain Python
$subDirs = Get-ChildItem -Path $folder.FullName -Directory -ErrorAction SilentlyContinue
foreach ($subDir in $subDirs) {
$pythonPath = "$($subDir.FullName)\python.exe"
if (Test-Path $pythonPath) {
try {
$version = & $pythonPath --version 2>&1
if ($version -match "Python") {
Write-Host "Found mapped Python in subdirectory: $pythonPath - $version"
return $pythonPath
}
} catch {
continue
}
}
}
}
# Fallback: try common Python commands that might be available
$pythonCommands = @("python", "py", "python3")
foreach ($cmd in $pythonCommands) {
try {
$version = & $cmd --version 2>&1
if ($version -match "Python") {
Write-Host "Found Python via command '$cmd': $version"
return $cmd
}
} catch {
continue
}
}
throw "Could not find any Python installation (mapped or otherwise)"
}
try {
# Step 1: Find the mapped Python installation
Write-Host "Step 1: Finding mapped Python installation..."
$pythonExe = Find-MappedPython
Write-Host "Using Python: $pythonExe"
# Verify Python works and show version
$pythonVersion = & $pythonExe --version 2>&1
Write-Host "Python version: $pythonVersion"
# Step 2: Install UV package manager (standalone, before creating venv)
Write-Host "Step 2: Installing UV package manager..."
try {
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
Write-Host "UV installed successfully"
} catch {
Write-Host "UV install warning: $($_.Exception.Message)"
throw "Failed to install UV"
}
# Step 3: Create virtual environment using UV
Write-Host "Step 3: Creating virtual environment with UV (if needed)..."
$cachePath = "C:\Users\WDAGUtilityAccount\Desktop\wsb_cache"
$venvPath = "C:\Users\WDAGUtilityAccount\Desktop\wsb_cache\venv"
if (!(Test-Path $venvPath)) {
Write-Host "Creating venv with UV at: $venvPath"
uv venv $venvPath
} else {
Write-Host "Venv already exists at: $venvPath"
}
# Hide the folder to keep Desktop clean
try {
$item = Get-Item $cachePath -ErrorAction SilentlyContinue
if ($item) {
if (-not ($item.Attributes -band [IO.FileAttributes]::Hidden)) {
$item.Attributes = $item.Attributes -bor [IO.FileAttributes]::Hidden
}
}
} catch { }
$venvPython = Join-Path $venvPath "Scripts\python.exe"
if (!(Test-Path $venvPython)) {
throw "Virtual environment Python not found at $venvPython"
}
Write-Host "Using venv Python: $venvPython"
# Step 4: Install cua-computer-server using UV
Write-Host "Step 4: Installing cua-computer-server with UV..."
uv pip install --project $venvPath cua-computer-server
Write-Host "cua-computer-server installation completed."
# Step 5: Start computer server in background using the venv Python
Write-Host "Step 5: Starting computer server in background..."
Write-Host "Starting computer server with: $venvPython"
# Start the computer server in the background
$serverProcess = Start-Process -FilePath $venvPython -ArgumentList "-m", "computer_server.main" -WindowStyle Hidden -PassThru
Write-Host "Computer server started in background with PID: $($serverProcess.Id)"
# Give it a moment to start
Start-Sleep -Seconds 3
# Check if the process is still running
if (Get-Process -Id $serverProcess.Id -ErrorAction SilentlyContinue) {
Write-Host "Computer server is running successfully in background"
} else {
throw "Computer server failed to start or exited immediately"
}
} catch {
Write-Error "Setup failed: $_"
Write-Host "Error details: $($_.Exception.Message)"
Write-Host "Stack trace: $($_.ScriptStackTrace)"
Write-Host ""
Write-Host "Press any key to close this window..."
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
exit 1
}
Write-Host ""
Write-Host "Setup completed successfully!"
Write-Host "Press any key to close this window..."
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
+385
View File
@@ -0,0 +1,385 @@
"""e2b-style PTY client for a running Computer instance.
Usage::
async with Computer(provider_type="docker", name="my-container") as c:
handle = await c.pty.create(
command="bash",
cols=80,
rows=24,
on_data=lambda d: print(d.decode(errors="replace"), end="", flush=True),
)
await handle.send_stdin(b"echo hello\\n")
await handle.send_stdin(b"exit\\n")
code = await handle.wait()
print(f"exited with {code}")
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
from dataclasses import dataclass, field
from typing import Callable, Optional
logger = logging.getLogger(__name__)
@dataclass
class PtyHandle:
"""Lightweight handle to a live PTY session on a remote computer-server."""
pid: int
cols: int
rows: int
_iface: "PtyInterface" = field(repr=False)
async def send_stdin(self, data: bytes) -> None:
"""Write *data* to the PTY's stdin."""
await self._iface.send_stdin(self.pid, data)
async def resize(self, cols: int, rows: int) -> None:
"""Resize the terminal window."""
await self._iface.resize(self.pid, cols, rows)
async def kill(self) -> bool:
"""Kill the PTY session process."""
return await self._iface.kill(self.pid)
async def disconnect(self) -> None:
"""Close the WebSocket connection without killing the PTY.
The session keeps running on the server; use :meth:`connect` to
re-attach later.
"""
await self._iface._disconnect(self.pid)
async def wait(self) -> int:
"""Block until the PTY session exits and return its exit code.
Raises:
LookupError: If the session for this handle's pid is not tracked
by the interface (e.g. the handle was never connected, or the
interface was recreated after a reconnect).
"""
return await self._iface._wait(self.pid)
class PtyInterface:
"""Async HTTP+WebSocket client for the ``/pty`` endpoints on computer-server.
Args:
base_url: HTTP base URL of the computer-server, e.g.
``"http://192.168.64.10:8000"``.
api_key: Optional API key for cloud providers (passed as
``X-API-Key`` header).
vm_name: Optional VM / container name (passed as
``X-Container-Name`` header).
"""
def __init__(
self,
base_url: str,
api_key: Optional[str] = None,
vm_name: Optional[str] = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._ws_base = self._base_url.replace("https://", "wss://").replace("http://", "ws://")
self._api_key = api_key
self._vm_name = vm_name
# pid → (asyncio.Event, exit_code_cell, ws_task)
self._sessions: dict[int, dict] = {}
# ------------------------------------------------------------------
# Auth helpers
# ------------------------------------------------------------------
def _auth_headers(self) -> dict:
headers = {}
if self._api_key:
headers["X-API-Key"] = self._api_key
if self._vm_name:
headers["X-Container-Name"] = self._vm_name
return headers
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def create(
self,
command: Optional[str] = None,
cols: int = 80,
rows: int = 24,
on_data: Optional[Callable[[bytes], None]] = None,
cwd: Optional[str] = None,
envs: Optional[dict] = None,
timeout: int = 60,
) -> PtyHandle:
"""Spawn a new PTY session on the remote computer-server.
Args:
command: Shell command (defaults to ``bash`` on the server side).
cols: Terminal width.
rows: Terminal height.
on_data: Callback invoked with raw bytes whenever the PTY produces
output. Called from the asyncio event loop.
cwd: Working directory on the remote host.
envs: Extra environment variables for the remote process.
timeout: HTTP request timeout in seconds.
Returns:
:class:`PtyHandle` for the new session.
"""
import aiohttp
body: dict = {"cols": cols, "rows": rows}
if command is not None:
body["command"] = command
if cwd is not None:
body["cwd"] = cwd
if envs is not None:
body["envs"] = envs
async with aiohttp.ClientSession() as http:
async with http.post(
f"{self._base_url}/pty",
json=body,
headers=self._auth_headers(),
timeout=aiohttp.ClientTimeout(total=timeout),
) as resp:
resp.raise_for_status()
data = await resp.json()
pid: int = data["pid"]
logger.debug("PTY session created: pid=%d", pid)
# Set up session state
exit_event: asyncio.Event = asyncio.Event()
exit_code_cell: list[int] = [0]
self._sessions[pid] = {
"exit_event": exit_event,
"exit_code": exit_code_cell,
"ws_task": None,
}
# Open WebSocket for bidirectional I/O
ws_task = asyncio.create_task(
self._ws_reader(pid, on_data, exit_event, exit_code_cell),
name=f"pty-ws-{pid}",
)
self._sessions[pid]["ws_task"] = ws_task
return PtyHandle(pid=pid, cols=data["cols"], rows=data["rows"], _iface=self)
async def kill(self, pid: int) -> bool:
"""Kill the remote PTY session *pid*."""
import aiohttp
async with aiohttp.ClientSession() as http:
async with http.delete(
f"{self._base_url}/pty/{pid}",
headers=self._auth_headers(),
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
resp.raise_for_status()
data = await resp.json()
return bool(data.get("killed"))
async def resize(self, pid: int, cols: int, rows: int) -> None:
"""Resize the terminal for the remote PTY session *pid*."""
import aiohttp
async with aiohttp.ClientSession() as http:
async with http.post(
f"{self._base_url}/pty/{pid}/resize",
json={"cols": cols, "rows": rows},
headers=self._auth_headers(),
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
resp.raise_for_status()
async def send_stdin(self, pid: int, data: bytes) -> None:
"""Write *data* to the remote PTY session's stdin via the WebSocket.
If a WebSocket is active the data is sent through it; otherwise falls
back to the HTTP ``/stdin`` endpoint.
"""
sess = self._sessions.get(pid)
ws_task = sess.get("ws_task") if sess else None
if ws_task and not ws_task.done():
# Route through the WS task via a shared queue
q = sess.get("stdin_queue")
if q is not None:
await q.put(data)
return
# Fallback: HTTP POST
import aiohttp
async with aiohttp.ClientSession() as http:
async with http.post(
f"{self._base_url}/pty/{pid}/stdin",
json={"data": base64.b64encode(data).decode()},
headers=self._auth_headers(),
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
resp.raise_for_status()
async def connect(
self,
pid: int,
on_data: Optional[Callable[[bytes], None]] = None,
) -> PtyHandle:
"""Re-open a WebSocket to an existing PTY session *pid*.
Any previous connection for this pid is cancelled first.
The current terminal dimensions are fetched from the server so that
the returned :class:`PtyHandle` reflects any resizes since creation.
"""
import aiohttp
sess = self._sessions.get(pid)
if sess:
ws_task = sess.get("ws_task")
if ws_task and not ws_task.done():
ws_task.cancel()
try:
await ws_task
except (asyncio.CancelledError, Exception):
pass
# Fetch current dimensions from the server.
cols, rows = 80, 24
try:
async with aiohttp.ClientSession() as http:
async with http.get(
f"{self._base_url}/pty/{pid}",
headers=self._auth_headers(),
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
info = await resp.json()
cols = int(info.get("cols", cols))
rows = int(info.get("rows", rows))
except Exception as exc:
logger.debug("PTY connect: could not fetch dimensions for pid %d: %s", pid, exc)
exit_event: asyncio.Event = asyncio.Event()
exit_code_cell: list[int] = [0]
self._sessions[pid] = {
"exit_event": exit_event,
"exit_code": exit_code_cell,
"ws_task": None,
}
ws_task = asyncio.create_task(
self._ws_reader(pid, on_data, exit_event, exit_code_cell),
name=f"pty-ws-reconnect-{pid}",
)
self._sessions[pid]["ws_task"] = ws_task
return PtyHandle(pid=pid, cols=cols, rows=rows, _iface=self)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _disconnect(self, pid: int) -> None:
"""Cancel the WS reader task without killing the remote process."""
sess = self._sessions.get(pid)
if not sess:
return
ws_task = sess.get("ws_task")
if ws_task and not ws_task.done():
ws_task.cancel()
try:
await ws_task
except (asyncio.CancelledError, Exception):
pass
async def _wait(self, pid: int) -> int:
"""Wait for the exit event and return the exit code.
Raises:
LookupError: If *pid* is not a tracked session.
"""
sess = self._sessions.get(pid)
if not sess:
raise LookupError(f"PTY session {pid} is not tracked by this interface")
await sess["exit_event"].wait()
return sess["exit_code"][0]
async def _ws_reader(
self,
pid: int,
on_data: Optional[Callable[[bytes], None]],
exit_event: asyncio.Event,
exit_code_cell: list[int],
) -> None:
"""Background coroutine: connect to the WS and forward messages."""
import aiohttp
stdin_queue: asyncio.Queue[bytes] = asyncio.Queue()
sess = self._sessions.setdefault(pid, {})
sess["stdin_queue"] = stdin_queue
params = {}
if self._api_key:
params["api_key"] = self._api_key
if self._vm_name:
params["container_name"] = self._vm_name
ws_url = f"{self._ws_base}/pty/{pid}/ws"
try:
async with aiohttp.ClientSession() as http:
async with http.ws_connect(ws_url, params=params) as ws:
async def _write_stdin():
while True:
data = await stdin_queue.get()
payload = json.dumps(
{
"type": "stdin",
"data": base64.b64encode(data).decode(),
}
)
await ws.send_str(payload)
writer_task = asyncio.create_task(_write_stdin())
try:
async for raw_msg in ws:
if raw_msg.type == aiohttp.WSMsgType.TEXT:
try:
msg = json.loads(raw_msg.data)
except json.JSONDecodeError:
continue
if msg.get("type") == "output":
chunk = base64.b64decode(msg["data"])
if on_data is not None:
on_data(chunk)
elif msg.get("type") == "exit":
exit_code_cell[0] = int(msg.get("code", 0))
break
elif raw_msg.type in (
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.ERROR,
):
break
finally:
writer_task.cancel()
try:
await writer_task
except (asyncio.CancelledError, Exception):
pass
except asyncio.CancelledError:
pass
except Exception as exc:
logger.debug("PTY WS reader error for pid %d: %s", pid, exc)
finally:
exit_event.set()
+379
View File
@@ -0,0 +1,379 @@
"""
Computer tracing functionality for recording sessions.
This module provides a Computer.tracing API inspired by Playwright's tracing functionality,
allowing users to record computer interactions for debugging, training, and analysis.
"""
import asyncio
import base64
import io
import json
import time
import uuid
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from PIL import Image
class ComputerTracing:
"""
Computer tracing class that records computer interactions and saves them to disk.
This class provides a flexible API for recording computer sessions with configurable
options for what to record (screenshots, API calls, video, etc.).
"""
def __init__(self, computer_instance):
"""
Initialize the tracing instance.
Args:
computer_instance: The Computer instance to trace
"""
self._computer = computer_instance
self._is_tracing = False
self._trace_config: Dict[str, Any] = {}
self._trace_data: List[Dict[str, Any]] = []
self._trace_start_time: Optional[float] = None
self._trace_id: Optional[str] = None
self._trace_dir: Optional[Path] = None
self._screenshot_count = 0
@property
def is_tracing(self) -> bool:
"""Check if tracing is currently active."""
return self._is_tracing
async def start(self, config: Optional[Dict[str, Any]] = None) -> None:
"""
Start tracing with the specified configuration.
Args:
config: Tracing configuration dict with options:
- video: bool - Record video frames (default: False)
- screenshots: bool - Record screenshots (default: True)
- api_calls: bool - Record API calls and results (default: True)
- accessibility_tree: bool - Record accessibility tree snapshots (default: False)
- metadata: bool - Record custom metadata (default: True)
- name: str - Custom trace name (default: auto-generated)
- path: str - Custom trace directory path (default: auto-generated)
"""
if self._is_tracing:
raise RuntimeError("Tracing is already active. Call stop() first.")
# Set default configuration
default_config = {
"video": False,
"screenshots": True,
"api_calls": True,
"accessibility_tree": False,
"metadata": True,
"name": None,
"path": None,
}
self._trace_config = {**default_config, **(config or {})}
# Generate trace ID and directory
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self._trace_id = (
self._trace_config.get("name") or f"trace_{timestamp}_{str(uuid.uuid4())[:8]}"
)
if self._trace_config.get("path"):
self._trace_dir = Path(self._trace_config["path"])
else:
self._trace_dir = Path.cwd() / "traces" / self._trace_id
# Create trace directory
self._trace_dir.mkdir(parents=True, exist_ok=True)
# Initialize trace data
self._trace_data = []
self._trace_start_time = time.time()
self._screenshot_count = 0
self._is_tracing = True
# Record initial metadata
await self._record_event(
"trace_start",
{
"trace_id": self._trace_id,
"config": self._trace_config,
"timestamp": self._trace_start_time,
"computer_info": {
"os_type": self._computer.os_type,
"provider_type": str(self._computer.provider_type),
"image": self._computer.image,
},
},
)
# Take initial screenshot if enabled
if self._trace_config.get("screenshots"):
await self._take_screenshot("initial_screenshot")
async def stop(self, options: Optional[Dict[str, Any]] = None) -> str:
"""
Stop tracing and save the trace data.
Args:
options: Stop options dict with:
- path: str - Custom output path for the trace archive
- format: str - Output format ('zip' or 'dir', default: 'zip')
Returns:
str: Path to the saved trace file or directory
"""
if not self._is_tracing:
raise RuntimeError("Tracing is not active. Call start() first.")
if self._trace_start_time is None or self._trace_dir is None or self._trace_id is None:
raise RuntimeError("Tracing state is invalid.")
# Record final metadata
await self._record_event(
"trace_end",
{
"timestamp": time.time(),
"duration": time.time() - self._trace_start_time,
"total_events": len(self._trace_data),
"screenshot_count": self._screenshot_count,
},
)
# Take final screenshot if enabled
if self._trace_config.get("screenshots"):
await self._take_screenshot("final_screenshot")
# Save trace metadata
metadata_path = self._trace_dir / "trace_metadata.json"
with open(metadata_path, "w") as f:
json.dump(
{
"trace_id": self._trace_id,
"config": self._trace_config,
"start_time": self._trace_start_time,
"end_time": time.time(),
"duration": time.time() - self._trace_start_time,
"total_events": len(self._trace_data),
"screenshot_count": self._screenshot_count,
"events": self._trace_data,
},
f,
indent=2,
default=str,
)
# Determine output format and path
output_format = options.get("format", "zip") if options else "zip"
custom_path = options.get("path") if options else None
if output_format == "zip":
# Create zip file
if custom_path:
zip_path = Path(custom_path).resolve()
# Check if path exists as a directory
if zip_path.exists() and zip_path.is_dir():
raise ValueError(
f"Cannot create zip file at '{custom_path}': path exists as a directory. "
"Please specify a file path (e.g., 'traces/my_trace.zip') or use format='dir'."
)
else:
zip_path = self._trace_dir.parent / f"{self._trace_id}.zip"
await self._create_zip_archive(zip_path)
output_path = str(zip_path)
else:
# Return directory path
if custom_path:
# Move directory to custom path
custom_dir = Path(custom_path).resolve()
trace_dir_resolved = self._trace_dir.resolve()
# Check if custom_dir is an ancestor of the trace directory
is_subdirectory = False
try:
trace_dir_resolved.relative_to(custom_dir)
is_subdirectory = True
except ValueError:
# relative_to raises ValueError if not a subdirectory, which is what we want
pass
if is_subdirectory:
raise ValueError(
f"Cannot move trace directory to '{custom_path}': it would be moved into itself. "
f"Trace is currently at '{self._trace_dir}'. "
"Please specify a different target directory."
)
if custom_dir.exists():
import shutil
shutil.rmtree(custom_dir)
self._trace_dir.rename(custom_dir)
output_path = str(custom_dir)
else:
output_path = str(self._trace_dir)
# Reset tracing state
self._is_tracing = False
self._trace_config = {}
self._trace_data = []
self._trace_start_time = None
self._trace_id = None
self._screenshot_count = 0
return output_path
async def _record_event(self, event_type: str, data: Dict[str, Any]) -> None:
"""
Record a trace event.
Args:
event_type: Type of event (e.g., 'click', 'type', 'screenshot')
data: Event data
"""
if not self._is_tracing or self._trace_start_time is None or self._trace_dir is None:
return
event = {
"type": event_type,
"timestamp": time.time(),
"relative_time": time.time() - self._trace_start_time,
"data": data,
}
self._trace_data.append(event)
# Save event to individual file for large traces
event_file = self._trace_dir / f"event_{len(self._trace_data):06d}_{event_type}.json"
with open(event_file, "w") as f:
json.dump(event, f, indent=2, default=str)
async def _take_screenshot(self, name: str = "screenshot") -> Optional[str]:
"""
Take a screenshot and save it to the trace.
Args:
name: Name for the screenshot
Returns:
Optional[str]: Path to the saved screenshot, or None if screenshots disabled
"""
if (
not self._trace_config.get("screenshots")
or not self._computer.interface
or self._trace_dir is None
):
return None
try:
screenshot_bytes = await self._computer.interface.screenshot()
self._screenshot_count += 1
screenshot_filename = f"{self._screenshot_count:06d}_{name}.png"
screenshot_path = self._trace_dir / screenshot_filename
with open(screenshot_path, "wb") as f:
f.write(screenshot_bytes)
return str(screenshot_path)
except Exception as e:
# Log error but don't fail the trace
if hasattr(self._computer, "logger"):
self._computer.logger.warning(f"Failed to take screenshot: {e}")
return None
async def _create_zip_archive(self, zip_path: Path) -> None:
"""
Create a zip archive of the trace directory.
Args:
zip_path: Path where to save the zip file
"""
if self._trace_dir is None:
raise RuntimeError("Trace directory is not set")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for file_path in self._trace_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(self._trace_dir)
zipf.write(file_path, arcname)
async def record_api_call(
self,
method: str,
args: Dict[str, Any],
result: Any = None,
error: Optional[Exception] = None,
) -> None:
"""
Record an API call event.
Args:
method: The method name that was called
args: Arguments passed to the method
result: Result returned by the method
error: Exception raised by the method, if any
"""
if not self._trace_config.get("api_calls"):
return
# Take screenshot after certain actions if enabled
screenshot_path = None
screenshot_actions = [
"left_click",
"right_click",
"double_click",
"type_text",
"press_key",
"hotkey",
]
if method in screenshot_actions and self._trace_config.get("screenshots"):
screenshot_path = await self._take_screenshot(f"after_{method}")
# Record accessibility tree after certain actions if enabled
if method in screenshot_actions and self._trace_config.get("accessibility_tree"):
await self.record_accessibility_tree()
await self._record_event(
"api_call",
{
"method": method,
"args": args,
"result": str(result) if result is not None else None,
"error": str(error) if error else None,
"screenshot": screenshot_path,
"success": error is None,
},
)
async def record_accessibility_tree(self) -> None:
"""Record the current accessibility tree if enabled."""
if not self._trace_config.get("accessibility_tree") or not self._computer.interface:
return
try:
accessibility_tree = await self._computer.interface.get_accessibility_tree()
await self._record_event("accessibility_tree", {"tree": accessibility_tree})
except Exception as e:
if hasattr(self._computer, "logger"):
self._computer.logger.warning(f"Failed to record accessibility tree: {e}")
async def add_metadata(self, key: str, value: Any) -> None:
"""
Add custom metadata to the trace.
Args:
key: Metadata key
value: Metadata value
"""
if not self._trace_config.get("metadata"):
return
await self._record_event("metadata", {"key": key, "value": value})
@@ -0,0 +1,334 @@
"""
Tracing wrapper for computer interface that records API calls.
"""
from typing import Any, Dict, List, Optional, Tuple
from .interface.base import BaseComputerInterface
class TracingInterfaceWrapper:
"""
Wrapper class that intercepts computer interface calls and records them for tracing.
"""
def __init__(self, original_interface: BaseComputerInterface, tracing_instance):
"""
Initialize the tracing wrapper.
Args:
original_interface: The original computer interface
tracing_instance: The ComputerTracing instance
"""
self._original_interface = original_interface
self._tracing = tracing_instance
def __getattr__(self, name):
"""
Delegate attribute access to the original interface if not found in wrapper.
"""
return getattr(self._original_interface, name)
async def _record_call(
self,
method_name: str,
args: Dict[str, Any],
result: Any = None,
error: Optional[Exception] = None,
):
"""
Record an API call for tracing.
Args:
method_name: Name of the method called
args: Arguments passed to the method
result: Result returned by the method
error: Exception raised, if any
"""
if self._tracing.is_tracing:
await self._tracing.record_api_call(method_name, args, result, error)
# Mouse Actions
async def left_click(
self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None
) -> None:
"""Perform a left mouse button click."""
args = {"x": x, "y": y, "delay": delay}
error = None
try:
result = await self._original_interface.left_click(x, y, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("left_click", args, None, error)
async def right_click(
self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None
) -> None:
"""Perform a right mouse button click."""
args = {"x": x, "y": y, "delay": delay}
error = None
try:
result = await self._original_interface.right_click(x, y, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("right_click", args, None, error)
async def double_click(
self, x: Optional[int] = None, y: Optional[int] = None, delay: Optional[float] = None
) -> None:
"""Perform a double left mouse button click."""
args = {"x": x, "y": y, "delay": delay}
error = None
try:
result = await self._original_interface.double_click(x, y, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("double_click", args, None, error)
async def move_cursor(self, x: int, y: int, delay: Optional[float] = None) -> None:
"""Move the cursor to the specified screen coordinates."""
args = {"x": x, "y": y, "delay": delay}
error = None
try:
result = await self._original_interface.move_cursor(x, y, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("move_cursor", args, None, error)
async def drag_to(
self,
x: int,
y: int,
button: str = "left",
duration: float = 0.5,
delay: Optional[float] = None,
) -> None:
"""Drag from current position to specified coordinates."""
args = {"x": x, "y": y, "button": button, "duration": duration, "delay": delay}
error = None
try:
result = await self._original_interface.drag_to(x, y, button, duration, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("drag_to", args, None, error)
async def drag(
self,
path: List[Tuple[int, int]],
button: str = "left",
duration: float = 0.5,
delay: Optional[float] = None,
) -> None:
"""Drag the cursor along a path of coordinates."""
args = {"path": path, "button": button, "duration": duration, "delay": delay}
error = None
try:
result = await self._original_interface.drag(path, button, duration, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("drag", args, None, error)
# Keyboard Actions
async def key_down(self, key: str, delay: Optional[float] = None) -> None:
"""Press and hold a key."""
args = {"key": key, "delay": delay}
error = None
try:
result = await self._original_interface.key_down(key, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("key_down", args, None, error)
async def key_up(self, key: str, delay: Optional[float] = None) -> None:
"""Release a previously pressed key."""
args = {"key": key, "delay": delay}
error = None
try:
result = await self._original_interface.key_up(key, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("key_up", args, None, error)
async def type_text(self, text: str, delay: Optional[float] = None) -> None:
"""Type the specified text string."""
args = {"text": text, "delay": delay}
error = None
try:
result = await self._original_interface.type_text(text, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("type_text", args, None, error)
async def press_key(self, key: str, delay: Optional[float] = None) -> None:
"""Press and release a single key."""
args = {"key": key, "delay": delay}
error = None
try:
result = await self._original_interface.press_key(key, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("press_key", args, None, error)
async def hotkey(self, *keys: str, delay: Optional[float] = None) -> None:
"""Press multiple keys simultaneously (keyboard shortcut)."""
args = {"keys": keys, "delay": delay}
error = None
try:
result = await self._original_interface.hotkey(*keys, delay=delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("hotkey", args, None, error)
# Scrolling Actions
async def scroll(self, x: int, y: int, delay: Optional[float] = None) -> None:
"""Scroll the mouse wheel by specified amounts."""
args = {"x": x, "y": y, "delay": delay}
error = None
try:
result = await self._original_interface.scroll(x, y, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("scroll", args, None, error)
async def scroll_down(self, clicks: int = 1, delay: Optional[float] = None) -> None:
"""Scroll down by the specified number of clicks."""
args = {"clicks": clicks, "delay": delay}
error = None
try:
result = await self._original_interface.scroll_down(clicks, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("scroll_down", args, None, error)
async def scroll_up(self, clicks: int = 1, delay: Optional[float] = None) -> None:
"""Scroll up by the specified number of clicks."""
args = {"clicks": clicks, "delay": delay}
error = None
try:
result = await self._original_interface.scroll_up(clicks, delay)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("scroll_up", args, None, error)
# Screen Actions
async def screenshot(self) -> bytes:
"""Take a screenshot."""
args = {}
error = None
result = None
try:
result = await self._original_interface.screenshot()
return result
except Exception as e:
error = e
raise
finally:
# For screenshots, we don't want to include the raw bytes in the trace args
await self._record_call(
"screenshot", args, "screenshot_taken" if result else None, error
)
async def get_screen_size(self) -> Dict[str, int]:
"""Get the screen dimensions."""
args = {}
error = None
result = None
try:
result = await self._original_interface.get_screen_size()
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("get_screen_size", args, result, error)
async def get_cursor_position(self) -> Dict[str, int]:
"""Get the current cursor position on screen."""
args = {}
error = None
result = None
try:
result = await self._original_interface.get_cursor_position()
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("get_cursor_position", args, result, error)
# Clipboard Actions
async def copy_to_clipboard(self) -> str:
"""Get the current clipboard content."""
args = {}
error = None
result = None
try:
result = await self._original_interface.copy_to_clipboard()
return result
except Exception as e:
error = e
raise
finally:
# Don't include clipboard content in trace for privacy
await self._record_call(
"copy_to_clipboard",
args,
f"content_length_{len(result)}" if result else None,
error,
)
async def set_clipboard(self, text: str) -> None:
"""Set the clipboard content to the specified text."""
# Don't include clipboard content in trace for privacy
args = {"text_length": len(text)}
error = None
try:
result = await self._original_interface.set_clipboard(text)
return result
except Exception as e:
error = e
raise
finally:
await self._record_call("set_clipboard", args, None, error)
@@ -0,0 +1 @@
"""UI modules for the Computer Interface."""
@@ -0,0 +1,15 @@
"""
Main entry point for computer.ui module.
This allows running the computer UI with:
python -m computer.ui
Instead of:
python -m computer.ui.gradio.app
"""
from .gradio.app import create_gradio_ui
if __name__ == "__main__":
app = create_gradio_ui()
app.launch()
@@ -0,0 +1,7 @@
"""Gradio UI for Computer UI."""
from typing import Optional
import gradio as gr
from .app import create_gradio_ui
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
import base64
import io
import os
import shlex
from typing import Any, Dict, Optional, Tuple
import mslex
from PIL import Image, ImageDraw
def decode_base64_image(base64_str: str) -> bytes:
"""Decode a base64 string into image bytes."""
return base64.b64decode(base64_str)
def encode_base64_image(image_bytes: bytes) -> str:
"""Encode image bytes to base64 string."""
return base64.b64encode(image_bytes).decode("utf-8")
def bytes_to_image(image_bytes: bytes) -> Image.Image:
"""Convert bytes to PIL Image.
Args:
image_bytes: Raw image bytes
Returns:
PIL.Image: The converted image
"""
return Image.open(io.BytesIO(image_bytes))
def image_to_bytes(image: Image.Image, format: str = "PNG") -> bytes:
"""Convert PIL Image to bytes."""
buf = io.BytesIO()
image.save(buf, format=format)
return buf.getvalue()
def resize_image(image_bytes: bytes, scale_factor: float) -> bytes:
"""Resize an image by a scale factor.
Args:
image_bytes: The original image as bytes
scale_factor: Factor to scale the image by (e.g., 0.5 for half size, 2.0 for double)
Returns:
bytes: The resized image as bytes
"""
image = bytes_to_image(image_bytes)
if scale_factor != 1.0:
new_size = (int(image.width * scale_factor), int(image.height * scale_factor))
image = image.resize(new_size, Image.Resampling.LANCZOS)
return image_to_bytes(image)
def draw_box(
image_bytes: bytes,
x: int,
y: int,
width: int,
height: int,
color: str = "#FF0000",
thickness: int = 2,
) -> bytes:
"""Draw a box on an image.
Args:
image_bytes: The original image as bytes
x: X coordinate of top-left corner
y: Y coordinate of top-left corner
width: Width of the box
height: Height of the box
color: Color of the box in hex format
thickness: Thickness of the box border in pixels
Returns:
bytes: The modified image as bytes
"""
# Convert bytes to PIL Image
image = bytes_to_image(image_bytes)
# Create drawing context
draw = ImageDraw.Draw(image)
# Draw rectangle
draw.rectangle([(x, y), (x + width, y + height)], outline=color, width=thickness)
# Convert back to bytes
return image_to_bytes(image)
def get_image_size(image_bytes: bytes) -> Tuple[int, int]:
"""Get the dimensions of an image.
Args:
image_bytes: The image as bytes
Returns:
Tuple[int, int]: Width and height of the image
"""
image = bytes_to_image(image_bytes)
return image.size
def parse_vm_info(vm_info: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Parse VM info from Lume API response."""
if not vm_info:
return None
def safe_join(argv: list[str]) -> str:
"""
Return a platform-correct string that safely quotes `argv` for shell execution.
- On POSIX: uses `shlex.join`.
- On Windows: uses `shlex.join`.
Args:
argv: iterable of argument strings (will be coerced to str).
Returns:
A safely quoted command-line string appropriate for the current platform that protects against
shell injection vulnerabilities.
"""
if os.name == "nt":
# On Windows, use mslex for proper quoting
return mslex.join(argv)
else:
# On POSIX systems, use shlex
return shlex.join(argv)