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
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:
@@ -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})
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user