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,977 @@
|
||||
"""Sandbox management commands for CUA CLI."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import webbrowser
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
from cua_cli.auth.store import get_api_key, require_api_key
|
||||
from cua_cli.utils.async_utils import run_async
|
||||
from cua_cli.utils.output import (
|
||||
print_error,
|
||||
print_info,
|
||||
print_json,
|
||||
print_success,
|
||||
print_table,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image string parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REGISTRY_HOSTNAMES = {
|
||||
"ghcr.io",
|
||||
"docker.io",
|
||||
"registry.hub.docker.com",
|
||||
"quay.io",
|
||||
"gcr.io",
|
||||
"registry.cua.ai",
|
||||
}
|
||||
|
||||
_MACOS_ALIASES = {"macos", "mac", "osx"}
|
||||
_LINUX_ALIASES = {"linux", "ubuntu", "debian", "fedora"}
|
||||
_WINDOWS_ALIASES = {"windows", "win"}
|
||||
_ANDROID_ALIASES = {"android"}
|
||||
|
||||
|
||||
def _parse_image(image_str: str, vm: bool = False):
|
||||
"""Parse an image string into a cua_sandbox Image object.
|
||||
|
||||
Examples::
|
||||
|
||||
"macos" -> Image.macos("26")
|
||||
"macos:sequoia" -> Image.macos("sequoia")
|
||||
"ubuntu:24.04" -> Image.linux("ubuntu", "24.04")
|
||||
"linux" -> Image.linux("ubuntu", "24.04")
|
||||
"windows:11" -> Image.windows("11")
|
||||
"android:14" -> Image.android("14")
|
||||
"ghcr.io/org/img" -> Image.from_registry("ghcr.io/org/img")
|
||||
"""
|
||||
from cua_sandbox import Image
|
||||
|
||||
# Registry reference: contains '/' or starts with a known registry hostname
|
||||
if "/" in image_str:
|
||||
host = image_str.split("/")[0]
|
||||
if "." in host or host in _REGISTRY_HOSTNAMES:
|
||||
return Image.from_registry(image_str)
|
||||
for rh in _REGISTRY_HOSTNAMES:
|
||||
if image_str.startswith(rh):
|
||||
return Image.from_registry(image_str)
|
||||
|
||||
# Split on ':'
|
||||
parts = image_str.split(":", 1)
|
||||
base = parts[0].lower()
|
||||
tag = parts[1] if len(parts) > 1 else None
|
||||
|
||||
if base in _MACOS_ALIASES:
|
||||
version = tag or "26"
|
||||
return Image.macos(version)
|
||||
|
||||
if base in _LINUX_ALIASES:
|
||||
distro = base if base != "linux" else "ubuntu"
|
||||
version = tag or "24.04"
|
||||
kind = "vm" if vm else "container"
|
||||
return Image.linux(distro, version, kind=kind)
|
||||
|
||||
if base in _WINDOWS_ALIASES:
|
||||
version = tag or "11"
|
||||
return Image.windows(version)
|
||||
|
||||
if base in _ANDROID_ALIASES:
|
||||
version = tag or "14"
|
||||
return Image.android(version)
|
||||
|
||||
# Unknown — try registry
|
||||
return Image.from_registry(image_str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory / disk parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_memory(s: str) -> int:
|
||||
"""Convert a memory string to MB.
|
||||
|
||||
"8GB" -> 8192
|
||||
"8192MB" -> 8192
|
||||
"8" -> 8192 (bare number treated as GB)
|
||||
"""
|
||||
s = s.strip()
|
||||
if s.upper().endswith("GB"):
|
||||
return int(float(s[:-2])) * 1024
|
||||
if s.upper().endswith("MB"):
|
||||
return int(float(s[:-2]))
|
||||
# Bare number — treat as GB
|
||||
return int(float(s)) * 1024
|
||||
|
||||
|
||||
def _parse_disk(s: str) -> int:
|
||||
"""Convert a disk string to GB.
|
||||
|
||||
"50GB" -> 50
|
||||
"50" -> 50
|
||||
"51200MB" -> 50
|
||||
"""
|
||||
s = s.strip()
|
||||
if s.upper().endswith("GB"):
|
||||
return int(float(s[:-2]))
|
||||
if s.upper().endswith("MB"):
|
||||
return int(float(s[:-2])) // 1024
|
||||
return int(float(s))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox API URL resolution (for shell / exec)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_sandbox_api_url(name: str, local: bool) -> tuple[str, Optional[str]]:
|
||||
"""Resolve the computer-server API URL and optional api_key for a sandbox.
|
||||
|
||||
Returns:
|
||||
Tuple of (api_url, api_key_or_none)
|
||||
"""
|
||||
if local:
|
||||
from cua_sandbox import sandbox_state
|
||||
|
||||
state = sandbox_state.load(name)
|
||||
if not state:
|
||||
raise ValueError(f"Local sandbox '{name}' not found. Check ~/.cua/sandboxes/")
|
||||
url = f"http://{state['host']}:{state['api_port']}"
|
||||
return url, None
|
||||
|
||||
api_key = require_api_key()
|
||||
from cua_sandbox.transport.cloud import CloudTransport, cloud_get_vm
|
||||
|
||||
vm = await cloud_get_vm(name, api_key=api_key)
|
||||
if not vm:
|
||||
raise ValueError(f"Sandbox '{name}' not found")
|
||||
if vm.get("status") == "not_found":
|
||||
raise ValueError(f"Sandbox '{name}' not found")
|
||||
url = CloudTransport._resolve_endpoint(vm)
|
||||
if not url:
|
||||
raise ValueError(f"Sandbox '{name}' has no API URL (is it running?)")
|
||||
return url, api_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_parser(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the sandbox command and subcommands."""
|
||||
for cmd_name in ("sandbox", "sb"):
|
||||
sb_parser = subparsers.add_parser(
|
||||
cmd_name,
|
||||
help="Sandbox management commands",
|
||||
description="Manage cloud and local sandboxes",
|
||||
)
|
||||
|
||||
sb_subparsers = sb_parser.add_subparsers(
|
||||
dest="sandbox_command",
|
||||
help="Sandbox command",
|
||||
)
|
||||
|
||||
# -- launch ----------------------------------------------------------
|
||||
launch_parser = sb_subparsers.add_parser(
|
||||
"launch",
|
||||
help="Launch a new sandbox",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"image",
|
||||
help="Image to launch (e.g. macos, ubuntu:24.04, windows:11)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Launch a local sandbox",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--name",
|
||||
default=None,
|
||||
help="Sandbox name",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--vm",
|
||||
action="store_true",
|
||||
help="Force VM kind for Linux images (default: container)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--cpu",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Number of vCPUs",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--memory",
|
||||
default=None,
|
||||
help="Memory (e.g. 8GB, 4096MB)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--disk",
|
||||
default=None,
|
||||
help="Disk size (e.g. 50GB)",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--region",
|
||||
default=None,
|
||||
help="Cloud region",
|
||||
)
|
||||
launch_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
# -- ls / list -------------------------------------------------------
|
||||
ls_parser = sb_subparsers.add_parser(
|
||||
"ls",
|
||||
aliases=["list"],
|
||||
help="List sandboxes",
|
||||
)
|
||||
ls_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="List local sandboxes",
|
||||
)
|
||||
ls_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="List both local and cloud sandboxes",
|
||||
)
|
||||
ls_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
# -- info ------------------------------------------------------------
|
||||
info_parser = sb_subparsers.add_parser(
|
||||
"info",
|
||||
aliases=["get"],
|
||||
help="Get sandbox details",
|
||||
)
|
||||
info_parser.add_argument(
|
||||
"name",
|
||||
help="Sandbox name",
|
||||
)
|
||||
info_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Target a local sandbox",
|
||||
)
|
||||
info_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
|
||||
# -- suspend ---------------------------------------------------------
|
||||
suspend_parser = sb_subparsers.add_parser(
|
||||
"suspend",
|
||||
help="Suspend a sandbox (preserves memory state)",
|
||||
)
|
||||
suspend_parser.add_argument("name", help="Sandbox name")
|
||||
suspend_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- resume ----------------------------------------------------------
|
||||
resume_parser = sb_subparsers.add_parser(
|
||||
"resume",
|
||||
help="Resume a suspended sandbox",
|
||||
)
|
||||
resume_parser.add_argument("name", help="Sandbox name")
|
||||
resume_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- restart ---------------------------------------------------------
|
||||
restart_parser = sb_subparsers.add_parser(
|
||||
"restart",
|
||||
help="Restart a sandbox",
|
||||
)
|
||||
restart_parser.add_argument("name", help="Sandbox name")
|
||||
restart_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- delete ----------------------------------------------------------
|
||||
delete_parser = sb_subparsers.add_parser(
|
||||
"delete",
|
||||
help="Delete a sandbox",
|
||||
)
|
||||
delete_parser.add_argument("name", help="Sandbox name")
|
||||
delete_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
delete_parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
|
||||
# -- vnc -------------------------------------------------------------
|
||||
vnc_parser = sb_subparsers.add_parser(
|
||||
"vnc",
|
||||
help="Open sandbox in browser via VNC",
|
||||
)
|
||||
vnc_parser.add_argument("name", help="Sandbox name")
|
||||
vnc_parser.add_argument("--local", action="store_true", help="Target a local sandbox")
|
||||
|
||||
# -- shell -----------------------------------------------------------
|
||||
shell_parser = sb_subparsers.add_parser(
|
||||
"shell",
|
||||
help="Open interactive shell or run command in sandbox",
|
||||
)
|
||||
shell_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Target a local sandbox",
|
||||
)
|
||||
shell_parser.add_argument(
|
||||
"--cols",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Terminal width (default: auto-detect)",
|
||||
)
|
||||
shell_parser.add_argument(
|
||||
"--rows",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Terminal height (default: auto-detect)",
|
||||
)
|
||||
shell_parser.add_argument("name", help="Sandbox name")
|
||||
shell_parser.add_argument(
|
||||
"shell_command",
|
||||
nargs=argparse.REMAINDER,
|
||||
help="Command to run (optional, opens interactive shell if omitted)",
|
||||
)
|
||||
|
||||
# -- exec ------------------------------------------------------------
|
||||
exec_parser = sb_subparsers.add_parser(
|
||||
"exec",
|
||||
help="Execute command in sandbox (non-interactive)",
|
||||
)
|
||||
exec_parser.add_argument(
|
||||
"--local",
|
||||
action="store_true",
|
||||
help="Target a local sandbox",
|
||||
)
|
||||
exec_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output as JSON",
|
||||
)
|
||||
exec_parser.add_argument("name", help="Sandbox name")
|
||||
exec_parser.add_argument(
|
||||
"exec_command",
|
||||
nargs=argparse.REMAINDER,
|
||||
help="Command to execute",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# execute() dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
"""Execute sandbox command based on subcommand."""
|
||||
cmd = getattr(args, "sandbox_command", None)
|
||||
|
||||
if cmd == "launch":
|
||||
return cmd_launch(args)
|
||||
elif cmd in ("ls", "list"):
|
||||
return cmd_ls(args)
|
||||
elif cmd in ("info", "get"):
|
||||
return cmd_info(args)
|
||||
elif cmd == "suspend":
|
||||
return cmd_suspend(args)
|
||||
elif cmd == "resume":
|
||||
return cmd_resume(args)
|
||||
elif cmd == "restart":
|
||||
return cmd_restart(args)
|
||||
elif cmd == "delete":
|
||||
return cmd_delete(args)
|
||||
elif cmd == "vnc":
|
||||
return cmd_vnc(args)
|
||||
elif cmd == "shell":
|
||||
return cmd_shell(args)
|
||||
elif cmd == "exec":
|
||||
return cmd_exec(args)
|
||||
else:
|
||||
print_error("Usage: cua sandbox <command>")
|
||||
print_info("Commands: launch, ls, info, suspend, resume, restart, delete, vnc, shell, exec")
|
||||
return 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_launch(args: argparse.Namespace) -> int:
|
||||
"""Launch a new sandbox."""
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
image = _parse_image(args.image, vm=getattr(args, "vm", False))
|
||||
|
||||
memory_mb = _parse_memory(args.memory) if args.memory else None
|
||||
disk_gb = _parse_disk(args.disk) if args.disk else None
|
||||
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
try:
|
||||
if local:
|
||||
sb = await Sandbox.create(
|
||||
image,
|
||||
local=True,
|
||||
name=args.name,
|
||||
)
|
||||
else:
|
||||
create_kwargs: dict[str, Any] = {}
|
||||
if args.name:
|
||||
create_kwargs["name"] = args.name
|
||||
if args.region:
|
||||
create_kwargs["region"] = args.region
|
||||
if getattr(args, "cpu", None):
|
||||
create_kwargs["cpu"] = args.cpu
|
||||
if memory_mb is not None:
|
||||
create_kwargs["memory_mb"] = memory_mb
|
||||
if disk_gb is not None:
|
||||
create_kwargs["disk_gb"] = disk_gb
|
||||
api_key = require_api_key()
|
||||
create_kwargs["api_key"] = api_key
|
||||
sb = await Sandbox.create(image, **create_kwargs)
|
||||
|
||||
name = sb.name
|
||||
await sb.disconnect()
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
print_error(f"Failed to launch sandbox: {e!r}\n{traceback.format_exc()}")
|
||||
return 1
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print_json({"name": name, "status": "ready"})
|
||||
else:
|
||||
print_success(f"Sandbox '{name}' is ready")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_ls(args: argparse.Namespace) -> int:
|
||||
"""List sandboxes."""
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
show_all = getattr(args, "all", False)
|
||||
local = getattr(args, "local", False)
|
||||
as_json = getattr(args, "json", False)
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
if show_all or local:
|
||||
try:
|
||||
local_list = await Sandbox.list(local=True)
|
||||
for s in local_list:
|
||||
results.append(
|
||||
{
|
||||
"name": s.name,
|
||||
"status": s.status,
|
||||
"source": getattr(s, "source", "local"),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if show_all or not local:
|
||||
try:
|
||||
api_key = get_api_key()
|
||||
cloud_list = await Sandbox.list(local=False, api_key=api_key)
|
||||
for s in cloud_list:
|
||||
results.append(
|
||||
{
|
||||
"name": s.name,
|
||||
"status": s.status,
|
||||
"source": "cloud",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if as_json:
|
||||
print_json(results)
|
||||
return 0
|
||||
|
||||
if not results:
|
||||
print_info("No sandboxes found.")
|
||||
return 0
|
||||
|
||||
print_table(
|
||||
results,
|
||||
[("name", "NAME"), ("status", "STATUS"), ("source", "SOURCE")],
|
||||
)
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> int:
|
||||
"""Get sandbox details."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
info = await Sandbox.get_info(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
if getattr(args, "json", False):
|
||||
data = {
|
||||
"name": info.name,
|
||||
"status": info.status,
|
||||
}
|
||||
for attr in ("os_type", "host", "region", "created_at", "cpu", "memory_mb", "disk_gb"):
|
||||
val = getattr(info, attr, None)
|
||||
if val is not None:
|
||||
data[attr] = val
|
||||
print_json(data)
|
||||
return 0
|
||||
|
||||
print_info(f"Name: {info.name}")
|
||||
print_info(f"Status: {info.status}")
|
||||
for attr, label in [
|
||||
("os_type", "OS"),
|
||||
("host", "Host"),
|
||||
("region", "Region"),
|
||||
("created_at", "Created"),
|
||||
]:
|
||||
val = getattr(info, attr, None)
|
||||
if val:
|
||||
print_info(f"{label}: {val}")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_suspend(args: argparse.Namespace) -> int:
|
||||
"""Suspend a sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.suspend(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to suspend sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is suspending.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_resume(args: argparse.Namespace) -> int:
|
||||
"""Resume a suspended sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.resume(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to resume sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is resuming.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_restart(args: argparse.Namespace) -> int:
|
||||
"""Restart a sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.restart(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to restart sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is restarting.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_delete(args: argparse.Namespace) -> int:
|
||||
"""Delete a sandbox."""
|
||||
local = getattr(args, "local", False)
|
||||
force = getattr(args, "force", False)
|
||||
|
||||
if not force:
|
||||
import sys
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
force = True
|
||||
else:
|
||||
try:
|
||||
answer = input(f"Delete sandbox '{args.name}'? [y/N] ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return 1
|
||||
if answer not in ("y", "yes"):
|
||||
print_info("Aborted.")
|
||||
return 0
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
await Sandbox.delete(args.name, local=local, **kwargs)
|
||||
except Exception as e:
|
||||
print_error(f"Failed to delete sandbox: {e}")
|
||||
return 1
|
||||
print_success(f"Sandbox '{args.name}' is being deleted.")
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_vnc(args: argparse.Namespace) -> int:
|
||||
"""Open sandbox in browser via VNC."""
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
from cua_sandbox import Sandbox
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if not local:
|
||||
kwargs["api_key"] = require_api_key()
|
||||
sb = await Sandbox.connect(args.name, local=local, **kwargs)
|
||||
vnc_url = await sb.get_display_url(share=True)
|
||||
await sb.disconnect()
|
||||
except Exception as e:
|
||||
print_error(f"Failed to get VNC URL: {e}")
|
||||
return 1
|
||||
|
||||
print_info(f"Opening VNC: {vnc_url}")
|
||||
webbrowser.open(vnc_url)
|
||||
return 0
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PTY / WebSocket helpers (shell / exec)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _default_shell() -> str:
|
||||
return "powershell" if sys.platform == "win32" else "bash"
|
||||
|
||||
|
||||
async def _shell_interactive(
|
||||
name: str,
|
||||
api_url: str,
|
||||
api_key: Optional[str],
|
||||
command: Optional[str],
|
||||
cols: Optional[int],
|
||||
rows: Optional[int],
|
||||
) -> int:
|
||||
"""Run an interactive PTY session via WebSocket."""
|
||||
import signal
|
||||
import threading
|
||||
|
||||
_auto_cols, _auto_rows = shutil.get_terminal_size((80, 24))
|
||||
cols = cols if cols is not None else _auto_cols
|
||||
rows = rows if rows is not None else _auto_rows
|
||||
|
||||
ws_url = api_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
headers: dict[str, str] = {
|
||||
"X-Container-Name": name,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
ws_params: dict[str, str] = {
|
||||
"container_name": name,
|
||||
}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
ws_params["api_key"] = api_key
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"{api_url}/pty",
|
||||
json={"command": command or _default_shell(), "cols": cols, "rows": rows},
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
raise ValueError("Authentication failed. Is the sandbox running?")
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
except aiohttp.ClientResponseError as e:
|
||||
raise ValueError(f"PTY unavailable: {e.status} {e.message}") from e
|
||||
except aiohttp.ClientError as e:
|
||||
raise ValueError(f"Connection failed: {e}") from e
|
||||
|
||||
pid: int = data["pid"]
|
||||
exit_code_cell: list[int] = [0]
|
||||
done_event = asyncio.Event()
|
||||
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
async def _run_ws() -> None:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.ws_connect(f"{ws_url}/pty/{pid}/ws", params=ws_params) as ws:
|
||||
|
||||
def _stdin_loop() -> None:
|
||||
while not done_event.is_set():
|
||||
try:
|
||||
ch = msvcrt.getch()
|
||||
if ch:
|
||||
encoded = base64.b64encode(ch).decode()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ws.send_str(json.dumps({"type": "stdin", "data": encoded})),
|
||||
asyncio.get_event_loop(),
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
t = threading.Thread(target=_stdin_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
async for raw_msg in ws:
|
||||
if raw_msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
msg = json.loads(raw_msg.data)
|
||||
except Exception:
|
||||
continue
|
||||
if msg.get("type") == "output":
|
||||
chunk = base64.b64decode(msg["data"])
|
||||
sys.stdout.buffer.write(chunk)
|
||||
sys.stdout.buffer.flush()
|
||||
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
|
||||
done_event.set()
|
||||
|
||||
await _run_ws()
|
||||
else:
|
||||
import termios
|
||||
import tty
|
||||
|
||||
old_settings = termios.tcgetattr(sys.stdin.fileno())
|
||||
tty.setraw(sys.stdin.fileno())
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _run_ws() -> None:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.ws_connect(f"{ws_url}/pty/{pid}/ws", params=ws_params) as ws:
|
||||
|
||||
def _resize(_sig=None, _frame=None) -> None:
|
||||
c, r = shutil.get_terminal_size((80, 24))
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ws.send_str(json.dumps({"type": "resize", "cols": c, "rows": r})),
|
||||
loop,
|
||||
)
|
||||
|
||||
signal.signal(signal.SIGWINCH, _resize)
|
||||
|
||||
def _stdin_loop() -> None:
|
||||
while not done_event.is_set():
|
||||
try:
|
||||
ch = sys.stdin.buffer.read(1)
|
||||
if not ch:
|
||||
break
|
||||
encoded = base64.b64encode(ch).decode()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ws.send_str(json.dumps({"type": "stdin", "data": encoded})),
|
||||
loop,
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
t = threading.Thread(target=_stdin_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
async for raw_msg in ws:
|
||||
if raw_msg.type == aiohttp.WSMsgType.TEXT:
|
||||
try:
|
||||
msg = json.loads(raw_msg.data)
|
||||
except Exception:
|
||||
continue
|
||||
if msg.get("type") == "output":
|
||||
chunk = base64.b64decode(msg["data"])
|
||||
sys.stdout.buffer.write(chunk)
|
||||
sys.stdout.buffer.flush()
|
||||
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
|
||||
done_event.set()
|
||||
|
||||
try:
|
||||
await _run_ws()
|
||||
finally:
|
||||
try:
|
||||
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_settings)
|
||||
except Exception:
|
||||
pass
|
||||
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
|
||||
|
||||
return exit_code_cell[0]
|
||||
|
||||
|
||||
async def _exec_noninteractive(
|
||||
name: str,
|
||||
api_url: str,
|
||||
api_key: Optional[str],
|
||||
command: str,
|
||||
) -> dict:
|
||||
"""Execute a command non-interactively and return result."""
|
||||
headers: dict[str, str] = {
|
||||
"X-Container-Name": name,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if api_key:
|
||||
headers["X-API-Key"] = api_key
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"{api_url}/cmd",
|
||||
json={"command": "run_command", "params": {"command": command}},
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
) as resp:
|
||||
if resp.status == 401:
|
||||
return {"success": False, "error": "Authentication failed"}
|
||||
text = await resp.text()
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
return json.loads(line[6:])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "error": f"Unexpected response: {text[:200]}"}
|
||||
|
||||
|
||||
def cmd_shell(args: argparse.Namespace) -> int:
|
||||
"""Open interactive shell or run command in sandbox."""
|
||||
command_parts = getattr(args, "shell_command", [])
|
||||
command = " ".join(command_parts).strip() if command_parts else None
|
||||
cols: Optional[int] = getattr(args, "cols", None)
|
||||
rows: Optional[int] = getattr(args, "rows", None)
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
async def _run() -> int:
|
||||
try:
|
||||
api_url, api_key = await _get_sandbox_api_url(args.name, local)
|
||||
except ValueError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
if not command:
|
||||
print_error("No command provided for non-interactive mode")
|
||||
return 1
|
||||
result = await _exec_noninteractive(args.name, api_url, api_key, command)
|
||||
if not result.get("success", True):
|
||||
print_error(result.get("error", "Command failed"))
|
||||
return 1
|
||||
stdout = result.get("stdout", "").strip()
|
||||
stderr = result.get("stderr", "").strip()
|
||||
returncode = result.get("returncode") or result.get("return_code") or 0
|
||||
if stdout:
|
||||
print(stdout)
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
return returncode
|
||||
|
||||
try:
|
||||
return await _shell_interactive(args.name, api_url, api_key, command, cols, rows)
|
||||
except ValueError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
return run_async(_run())
|
||||
|
||||
|
||||
def cmd_exec(args: argparse.Namespace) -> int:
|
||||
"""Execute command in sandbox (non-interactive)."""
|
||||
command_parts = getattr(args, "exec_command", [])
|
||||
command = " ".join(command_parts).strip() if command_parts else None
|
||||
local = getattr(args, "local", False)
|
||||
|
||||
if not command:
|
||||
print_error("No command provided")
|
||||
print_info("Usage: cua sb exec <name> <command>")
|
||||
return 1
|
||||
|
||||
async def _run() -> int:
|
||||
try:
|
||||
api_url, api_key = await _get_sandbox_api_url(args.name, local)
|
||||
except ValueError as e:
|
||||
print_error(str(e))
|
||||
return 1
|
||||
|
||||
result = await _exec_noninteractive(args.name, api_url, api_key, command)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print_json(result)
|
||||
return result.get("returncode") or result.get("return_code") or 0
|
||||
|
||||
if not result.get("success", True):
|
||||
print_error(result.get("error", "Command failed"))
|
||||
return 1
|
||||
|
||||
stdout = result.get("stdout", "").strip()
|
||||
stderr = result.get("stderr", "").strip()
|
||||
returncode = result.get("returncode") or result.get("return_code") or 0
|
||||
|
||||
if stdout:
|
||||
print(stdout)
|
||||
if stderr:
|
||||
print(stderr, file=sys.stderr)
|
||||
|
||||
return returncode
|
||||
|
||||
return run_async(_run())
|
||||
Reference in New Issue
Block a user