Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions libs/python/cua-sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,27 @@ async with Localhost.connect() as host:
await host.shell.run("echo hello")
await host.screenshot()
```


## Cloud sandbox

Fleet is the OAuth cloud backend. Configure OAuth credentials once; Fleet uses `https://run.cua.ai` by default and can be overridden with `configure(fleet_base_url=...)` or `CUA_FLEET_BASE_URL`. The legacy API-key VM API continues to use `https://api.cua.ai`. Cloud images must use a registry reference; `expose()` declares additional Fleet services.

Fleet does not support snapshots or custom disks, and currently supports only `us-east-1`. `await sb.tunnel.forward(3000)` returns the authenticated Fleet service URL for an exposed port; it does not open a local SSH tunnel.

```python
import os

import cua_sandbox as cua
from cua_sandbox import Image, Sandbox

cua.configure(
client_id=os.environ["CUA_CLIENT_ID"],
client_secret=os.environ["CUA_CLIENT_SECRET"],
)

async with Sandbox.ephemeral(
Image.from_registry("registry.example/desktop-workspace@sha256:...").expose(3000)
) as sb:
await sb.shell.run("uname -a")
```
68 changes: 50 additions & 18 deletions libs/python/cua-sandbox/cua_sandbox/_config.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
"""Global configuration — configure(api_key, base_url).

Auth priority: per-call > configure() > ~/.cua/credentials > CUA_API_KEY env var.
"""
"""Global configuration for cloud sandbox provisioning."""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Optional

_DEFAULT_BASE_URL = "https://api.cua.ai"
_DEFAULT_FLEET_BASE_URL = "https://run.cua.ai"
_DEFAULT_TOKEN_URL = "https://auth.cua.ai/realms/cyclops-cs/protocol/openid-connect/token"


@dataclass
class _Config:
api_key: Optional[str] = None
base_url: str = "https://api.cua.ai"
base_url: str = _DEFAULT_BASE_URL
fleet_base_url: str = _DEFAULT_FLEET_BASE_URL
token_url: str = _DEFAULT_TOKEN_URL
client_id: Optional[str] = None
client_secret: Optional[str] = None


_global_config = _Config()
Expand All @@ -23,42 +28,69 @@ def configure(
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
fleet_base_url: Optional[str] = None,
token_url: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
) -> None:
"""Set global configuration for the CUA SDK.
"""Set global configuration for cloud sandboxes.

Args:
api_key: API key for cloud sandboxes.
base_url: Base URL for the CUA cloud API.
API-key cloud operations use ``base_url``. Fleet uses ``fleet_base_url`` and
OAuth client credentials.
"""
if api_key is not None:
_global_config.api_key = api_key
if base_url is not None:
_global_config.base_url = base_url
if fleet_base_url is not None:
_global_config.fleet_base_url = fleet_base_url
if token_url is not None:
_global_config.token_url = token_url
if client_id is not None:
_global_config.client_id = client_id
if client_secret is not None:
_global_config.client_secret = client_secret


def get_api_key(override: Optional[str] = None) -> Optional[str]:
"""Resolve API key with priority: override > configure() > credentials file > env."""
"""Resolve a legacy API key with per-call configuration taking priority."""
if override:
return override
if _global_config.api_key:
return _global_config.api_key
# Try credentials file
cred = _read_credentials_key()
if cred:
return cred
credential = _read_credentials_key()
if credential:
return credential
return os.environ.get("CUA_API_KEY")


def get_base_url() -> str:
return os.environ.get("CUA_BASE_URL") or _global_config.base_url


def get_fleet_base_url() -> str:
"""Return the Fleet API endpoint without changing legacy VM API routing."""
return os.environ.get("CUA_FLEET_BASE_URL") or _global_config.fleet_base_url


def get_token_url() -> str:
return os.environ.get("CUA_TOKEN_URL") or _global_config.token_url


def get_client_id(override: Optional[str] = None) -> Optional[str]:
return override or _global_config.client_id or os.environ.get("CUA_CLIENT_ID")


def get_client_secret(override: Optional[str] = None) -> Optional[str]:
return override or _global_config.client_secret or os.environ.get("CUA_CLIENT_SECRET")


def _read_credentials_key() -> Optional[str]:
"""Read API key from ~/.cua/credentials if it exists."""
cred_path = os.path.join(os.path.expanduser("~"), ".cua", "credentials")
"""Read a legacy API key from ``~/.cua/credentials`` when present."""
credential_path = os.path.join(os.path.expanduser("~"), ".cua", "credentials")
try:
with open(cred_path) as f:
for line in f:
with open(credential_path) as credential_file:
for line in credential_file:
line = line.strip()
if line.startswith("api_key="):
return line[len("api_key=") :]
Expand Down
7 changes: 5 additions & 2 deletions libs/python/cua-sandbox/cua_sandbox/interfaces/tunnel.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@
class TunnelInfo:
"""A single forwarded port."""

def __init__(self, host: str, port: int, sandbox_port: int) -> None:
def __init__(
self, host: str, port: int, sandbox_port: int, *, url: Optional[str] = None
) -> None:
self.host = host
self.port = port # host-side port
self.sandbox_port = sandbox_port # original port inside sandbox
self._url = url
self._closer: Optional[object] = None # Callable[[TunnelInfo], Coroutine]

@property
def url(self) -> str:
return f"http://{self.host}:{self.port}"
return self._url or f"http://{self.host}:{self.port}"

async def close(self) -> None:
"""Close this tunnel (no-op if already closed or inside a context manager)."""
Expand Down
Loading
Loading