use-computer 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mmini/__init__.py +48 -0
- mmini/ax_transpile.py +504 -0
- mmini/client.py +362 -0
- mmini/display.py +39 -0
- mmini/errors.py +68 -0
- mmini/ios/__init__.py +14 -0
- mmini/ios/apps.py +57 -0
- mmini/ios/environment.py +49 -0
- mmini/ios/input.py +118 -0
- mmini/macos/__init__.py +4 -0
- mmini/macos/keyboard.py +59 -0
- mmini/macos/mouse.py +105 -0
- mmini/models.py +79 -0
- mmini/parsers.py +308 -0
- mmini/py.typed +1 -0
- mmini/recording.py +88 -0
- mmini/retry.py +135 -0
- mmini/sandbox.py +419 -0
- mmini/screenshot.py +63 -0
- mmini/tasks/__init__.py +307 -0
- mmini/tasks/templates/pre_command.sh +2 -0
- mmini/tasks/templates/task.toml +18 -0
- mmini/tasks/templates/test_ios.sh +20 -0
- mmini/tasks/templates/test_ios_nograder.sh +4 -0
- mmini/tasks/templates/test_macos.sh +10 -0
- mmini/tasks/templates/test_macos_check.sh +10 -0
- mmini/tasks/templates/test_macos_nograder.sh +7 -0
- use_computer/__init__.py +15 -0
- use_computer/py.typed +1 -0
- use_computer-0.0.1.dist-info/METADATA +133 -0
- use_computer-0.0.1.dist-info/RECORD +32 -0
- use_computer-0.0.1.dist-info/WHEEL +4 -0
mmini/recording.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from mmini.models import RecordingInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Recording:
|
|
9
|
+
def __init__(self, http: httpx.Client, prefix: str):
|
|
10
|
+
self._http = http
|
|
11
|
+
self._prefix = prefix
|
|
12
|
+
|
|
13
|
+
def start(self, name: str | None = None) -> RecordingInfo:
|
|
14
|
+
payload: dict = {}
|
|
15
|
+
if name:
|
|
16
|
+
payload["name"] = name
|
|
17
|
+
resp = self._http.post(f"{self._prefix}/recording/start", json=payload)
|
|
18
|
+
resp.raise_for_status()
|
|
19
|
+
return RecordingInfo.from_dict(resp.json())
|
|
20
|
+
|
|
21
|
+
def stop(self, recording_id: str) -> RecordingInfo:
|
|
22
|
+
resp = self._http.post(f"{self._prefix}/recording/stop", json={"id": recording_id})
|
|
23
|
+
resp.raise_for_status()
|
|
24
|
+
return RecordingInfo.from_dict(resp.json())
|
|
25
|
+
|
|
26
|
+
def list_all(self) -> list[RecordingInfo]:
|
|
27
|
+
resp = self._http.get(f"{self._prefix}/recordings")
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
return [RecordingInfo.from_dict(r) for r in resp.json()]
|
|
30
|
+
|
|
31
|
+
def get(self, recording_id: str) -> RecordingInfo:
|
|
32
|
+
resp = self._http.get(f"{self._prefix}/recordings/{recording_id}")
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
return RecordingInfo.from_dict(resp.json())
|
|
35
|
+
|
|
36
|
+
def download(self, recording_id: str, local_path: str, timeout: float = 30.0) -> None:
|
|
37
|
+
download_url = f"{self._prefix}/recordings/{recording_id}/download"
|
|
38
|
+
with self._http.stream("GET", download_url, timeout=timeout) as resp:
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
with open(local_path, "wb") as f:
|
|
41
|
+
for chunk in resp.iter_bytes(chunk_size=65536):
|
|
42
|
+
f.write(chunk)
|
|
43
|
+
|
|
44
|
+
def delete(self, recording_id: str) -> None:
|
|
45
|
+
resp = self._http.delete(f"{self._prefix}/recordings/{recording_id}")
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsyncRecording:
|
|
50
|
+
def __init__(self, http: httpx.AsyncClient, prefix: str):
|
|
51
|
+
self._http = http
|
|
52
|
+
self._prefix = prefix
|
|
53
|
+
|
|
54
|
+
async def start(self, name: str | None = None) -> RecordingInfo:
|
|
55
|
+
payload: dict = {}
|
|
56
|
+
if name:
|
|
57
|
+
payload["name"] = name
|
|
58
|
+
resp = await self._http.post(f"{self._prefix}/recording/start", json=payload)
|
|
59
|
+
resp.raise_for_status()
|
|
60
|
+
return RecordingInfo.from_dict(resp.json())
|
|
61
|
+
|
|
62
|
+
async def stop(self, recording_id: str) -> RecordingInfo:
|
|
63
|
+
resp = await self._http.post(f"{self._prefix}/recording/stop", json={"id": recording_id})
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
return RecordingInfo.from_dict(resp.json())
|
|
66
|
+
|
|
67
|
+
async def list_all(self) -> list[RecordingInfo]:
|
|
68
|
+
resp = await self._http.get(f"{self._prefix}/recordings")
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
return [RecordingInfo.from_dict(r) for r in resp.json()]
|
|
71
|
+
|
|
72
|
+
async def get(self, recording_id: str) -> RecordingInfo:
|
|
73
|
+
resp = await self._http.get(f"{self._prefix}/recordings/{recording_id}")
|
|
74
|
+
resp.raise_for_status()
|
|
75
|
+
return RecordingInfo.from_dict(resp.json())
|
|
76
|
+
|
|
77
|
+
async def download(self, recording_id: str, local_path: str, timeout: float = 30.0) -> None:
|
|
78
|
+
async with self._http.stream(
|
|
79
|
+
"GET", f"{self._prefix}/recordings/{recording_id}/download", timeout=timeout
|
|
80
|
+
) as resp:
|
|
81
|
+
resp.raise_for_status()
|
|
82
|
+
with open(local_path, "wb") as f:
|
|
83
|
+
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
|
84
|
+
f.write(chunk)
|
|
85
|
+
|
|
86
|
+
async def delete(self, recording_id: str) -> None:
|
|
87
|
+
resp = await self._http.delete(f"{self._prefix}/recordings/{recording_id}")
|
|
88
|
+
resp.raise_for_status()
|
mmini/retry.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Single retry layer for all mmini SDK HTTP calls.
|
|
2
|
+
|
|
3
|
+
Retries are handled at the httpx transport level so they apply
|
|
4
|
+
automatically to every request — no per-method wrappers needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
_log = logging.getLogger("mmini.retry")
|
|
16
|
+
|
|
17
|
+
RETRYABLE_EXCEPTIONS = (
|
|
18
|
+
httpx.TimeoutException,
|
|
19
|
+
httpx.ConnectError,
|
|
20
|
+
httpx.ReadError,
|
|
21
|
+
httpx.WriteError,
|
|
22
|
+
)
|
|
23
|
+
RETRYABLE_STATUS_CODES = frozenset({500, 502, 503, 504, 529})
|
|
24
|
+
NO_RETRY_PATTERNS = ("not found", "connection refused")
|
|
25
|
+
MAX_RETRIES = 3
|
|
26
|
+
RETRY_DELAY = 2.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_retryable_body(body: str) -> bool:
|
|
30
|
+
lower = body.lower()
|
|
31
|
+
return not any(p in lower for p in NO_RETRY_PATTERNS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RetryTransport(httpx.BaseTransport):
|
|
35
|
+
def __init__(self, transport: httpx.BaseTransport):
|
|
36
|
+
self._transport = transport
|
|
37
|
+
|
|
38
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
39
|
+
resp = None
|
|
40
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
41
|
+
try:
|
|
42
|
+
resp = self._transport.handle_request(request)
|
|
43
|
+
except RETRYABLE_EXCEPTIONS as exc:
|
|
44
|
+
if attempt == MAX_RETRIES:
|
|
45
|
+
raise
|
|
46
|
+
_log.warning(
|
|
47
|
+
"retry %d/%d: %s %s → %s",
|
|
48
|
+
attempt + 1,
|
|
49
|
+
MAX_RETRIES,
|
|
50
|
+
request.method,
|
|
51
|
+
request.url,
|
|
52
|
+
type(exc).__name__,
|
|
53
|
+
)
|
|
54
|
+
time.sleep(RETRY_DELAY)
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if resp.status_code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
|
|
58
|
+
return resp
|
|
59
|
+
|
|
60
|
+
body = ""
|
|
61
|
+
try:
|
|
62
|
+
resp.read()
|
|
63
|
+
body = resp.text[:500]
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
if not _is_retryable_body(body):
|
|
67
|
+
return resp
|
|
68
|
+
|
|
69
|
+
_log.warning(
|
|
70
|
+
"retry %d/%d: %s %s → %d %s",
|
|
71
|
+
attempt + 1,
|
|
72
|
+
MAX_RETRIES,
|
|
73
|
+
request.method,
|
|
74
|
+
request.url,
|
|
75
|
+
resp.status_code,
|
|
76
|
+
body,
|
|
77
|
+
)
|
|
78
|
+
resp.close()
|
|
79
|
+
time.sleep(RETRY_DELAY)
|
|
80
|
+
raise RuntimeError("retry transport exhausted without a response")
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
self._transport.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AsyncRetryTransport(httpx.AsyncBaseTransport):
|
|
87
|
+
def __init__(self, transport: httpx.AsyncBaseTransport):
|
|
88
|
+
self._transport = transport
|
|
89
|
+
|
|
90
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
91
|
+
resp = None
|
|
92
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
93
|
+
try:
|
|
94
|
+
resp = await self._transport.handle_async_request(request)
|
|
95
|
+
except RETRYABLE_EXCEPTIONS as exc:
|
|
96
|
+
if attempt == MAX_RETRIES:
|
|
97
|
+
raise
|
|
98
|
+
_log.warning(
|
|
99
|
+
"retry %d/%d: %s %s → %s",
|
|
100
|
+
attempt + 1,
|
|
101
|
+
MAX_RETRIES,
|
|
102
|
+
request.method,
|
|
103
|
+
request.url,
|
|
104
|
+
type(exc).__name__,
|
|
105
|
+
)
|
|
106
|
+
await asyncio.sleep(RETRY_DELAY)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if resp.status_code not in RETRYABLE_STATUS_CODES or attempt == MAX_RETRIES:
|
|
110
|
+
return resp
|
|
111
|
+
|
|
112
|
+
body = ""
|
|
113
|
+
try:
|
|
114
|
+
await resp.aread()
|
|
115
|
+
body = resp.text[:500]
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
if not _is_retryable_body(body):
|
|
119
|
+
return resp
|
|
120
|
+
|
|
121
|
+
_log.warning(
|
|
122
|
+
"retry %d/%d: %s %s → %d %s",
|
|
123
|
+
attempt + 1,
|
|
124
|
+
MAX_RETRIES,
|
|
125
|
+
request.method,
|
|
126
|
+
request.url,
|
|
127
|
+
resp.status_code,
|
|
128
|
+
body,
|
|
129
|
+
)
|
|
130
|
+
await resp.aclose()
|
|
131
|
+
await asyncio.sleep(RETRY_DELAY)
|
|
132
|
+
raise RuntimeError("async retry transport exhausted without a response")
|
|
133
|
+
|
|
134
|
+
async def aclose(self) -> None:
|
|
135
|
+
await self._transport.aclose()
|
mmini/sandbox.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import tarfile
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.request
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from mmini.display import AsyncDisplay, Display
|
|
13
|
+
from mmini.ios.apps import Apps, AsyncApps
|
|
14
|
+
from mmini.ios.environment import AsyncEnvironment, Environment
|
|
15
|
+
from mmini.ios.input import AsyncInput, Input
|
|
16
|
+
from mmini.macos.keyboard import AsyncKeyboard, Keyboard
|
|
17
|
+
from mmini.macos.mouse import AsyncMouse, Mouse
|
|
18
|
+
from mmini.models import ActResult, ExecResult
|
|
19
|
+
from mmini.recording import AsyncRecording, Recording
|
|
20
|
+
from mmini.screenshot import AsyncScreenshot, Screenshot
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SandboxType(str, Enum):
|
|
24
|
+
MACOS = "macos"
|
|
25
|
+
IOS = "ios"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Keepalive helper — shared by sync and async sandboxes
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _start_keepalive_thread(
|
|
34
|
+
http_client: httpx.Client | httpx.AsyncClient,
|
|
35
|
+
prefix: str,
|
|
36
|
+
interval: float,
|
|
37
|
+
) -> tuple[threading.Event, threading.Thread]:
|
|
38
|
+
"""Spawn a daemon thread that POSTs `<prefix>/keepalive` every `interval` seconds.
|
|
39
|
+
|
|
40
|
+
Uses urllib.request directly (not the SDK's httpx client) so the thread
|
|
41
|
+
runs independently of any asyncio loop or shared connection pool. This is
|
|
42
|
+
why it works for both sync Sandbox and AsyncSandbox: the thread never
|
|
43
|
+
touches the caller's HTTP client.
|
|
44
|
+
"""
|
|
45
|
+
base_url = str(http_client.base_url).rstrip("/")
|
|
46
|
+
keepalive_url = f"{base_url}{prefix}/keepalive"
|
|
47
|
+
auth = http_client.headers.get("Authorization", "")
|
|
48
|
+
headers = {"Authorization": auth} if auth else {}
|
|
49
|
+
|
|
50
|
+
stop = threading.Event()
|
|
51
|
+
|
|
52
|
+
def _loop() -> None:
|
|
53
|
+
while not stop.is_set():
|
|
54
|
+
stop.wait(interval)
|
|
55
|
+
if stop.is_set():
|
|
56
|
+
break
|
|
57
|
+
try:
|
|
58
|
+
req = urllib.request.Request(keepalive_url, method="POST", headers=headers)
|
|
59
|
+
urllib.request.urlopen(req, timeout=10).close()
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
thread = threading.Thread(target=_loop, daemon=True)
|
|
64
|
+
thread.start()
|
|
65
|
+
return stop, thread
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _stop_keepalive_thread(
|
|
69
|
+
stop: threading.Event | None,
|
|
70
|
+
thread: threading.Thread | None,
|
|
71
|
+
) -> None:
|
|
72
|
+
if stop:
|
|
73
|
+
stop.set()
|
|
74
|
+
if thread:
|
|
75
|
+
thread.join(timeout=2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Sync
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Sandbox:
|
|
84
|
+
"""Base sandbox — shared operations that work on both macOS and iOS."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
sandbox_id: str,
|
|
89
|
+
type: SandboxType,
|
|
90
|
+
http: httpx.Client,
|
|
91
|
+
*,
|
|
92
|
+
vnc_url: str = "",
|
|
93
|
+
ssh_url: str = "",
|
|
94
|
+
):
|
|
95
|
+
self.sandbox_id = sandbox_id
|
|
96
|
+
self.type = type
|
|
97
|
+
self.vnc_url = vnc_url
|
|
98
|
+
self.ssh_url = ssh_url
|
|
99
|
+
self._http = http
|
|
100
|
+
self._prefix = f"/v1/sandboxes/{sandbox_id}"
|
|
101
|
+
self._keepalive_stop: threading.Event | None = None
|
|
102
|
+
self._keepalive_thread: threading.Thread | None = None
|
|
103
|
+
|
|
104
|
+
self.screenshot = Screenshot(http, self._prefix)
|
|
105
|
+
self.recording = Recording(http, self._prefix)
|
|
106
|
+
self.display = Display(http, self._prefix)
|
|
107
|
+
|
|
108
|
+
def upload(self, local_path: str | Path, remote_path: str) -> None:
|
|
109
|
+
with open(local_path, "rb") as f:
|
|
110
|
+
data = f.read()
|
|
111
|
+
self.upload_bytes(data, remote_path)
|
|
112
|
+
|
|
113
|
+
def upload_bytes(self, data: bytes, remote_path: str) -> None:
|
|
114
|
+
# NOTE: parent directories must exist — SCP won't create them.
|
|
115
|
+
# Use upload_dir() for nested paths or mkdir -p via exec_ssh first.
|
|
116
|
+
resp = self._http.put(
|
|
117
|
+
f"{self._prefix}/files",
|
|
118
|
+
params={"path": remote_path},
|
|
119
|
+
content=data,
|
|
120
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
121
|
+
)
|
|
122
|
+
resp.raise_for_status()
|
|
123
|
+
|
|
124
|
+
def download_file(self, remote_path: str, local_path: str | Path) -> None:
|
|
125
|
+
local_path = Path(local_path)
|
|
126
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
resp = self._http.get(f"{self._prefix}/files", params={"path": remote_path})
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
local_path.write_bytes(resp.content)
|
|
130
|
+
|
|
131
|
+
def start_keepalive(self, interval: float = 30.0) -> None:
|
|
132
|
+
"""Start a background thread that pings the gateway every `interval` seconds."""
|
|
133
|
+
self._keepalive_stop, self._keepalive_thread = _start_keepalive_thread(
|
|
134
|
+
self._http, self._prefix, interval
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def stop_keepalive(self) -> None:
|
|
138
|
+
"""Stop the keepalive background thread."""
|
|
139
|
+
_stop_keepalive_thread(self._keepalive_stop, self._keepalive_thread)
|
|
140
|
+
self._keepalive_stop = None
|
|
141
|
+
self._keepalive_thread = None
|
|
142
|
+
|
|
143
|
+
def close(self):
|
|
144
|
+
self.stop_keepalive()
|
|
145
|
+
self._http.delete(f"{self._prefix}")
|
|
146
|
+
|
|
147
|
+
def __enter__(self):
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def __exit__(self, *_):
|
|
151
|
+
self.close()
|
|
152
|
+
|
|
153
|
+
def __repr__(self):
|
|
154
|
+
return f"Sandbox({self.sandbox_id!r}, type={self.type.value!r})"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MacOSSandbox(Sandbox):
|
|
158
|
+
"""macOS VM sandbox — full mouse, keyboard, SSH exec, VNC."""
|
|
159
|
+
|
|
160
|
+
def __init__(
|
|
161
|
+
self,
|
|
162
|
+
sandbox_id: str,
|
|
163
|
+
http: httpx.Client,
|
|
164
|
+
*,
|
|
165
|
+
vnc_url: str = "",
|
|
166
|
+
ssh_url: str = "",
|
|
167
|
+
vm_ip: str = "",
|
|
168
|
+
host: str = "",
|
|
169
|
+
):
|
|
170
|
+
super().__init__(sandbox_id, SandboxType.MACOS, http, vnc_url=vnc_url, ssh_url=ssh_url)
|
|
171
|
+
self.vm_ip = vm_ip
|
|
172
|
+
self.host = host
|
|
173
|
+
self.mouse = Mouse(http, self._prefix)
|
|
174
|
+
self.keyboard = Keyboard(http, self._prefix)
|
|
175
|
+
|
|
176
|
+
def act(
|
|
177
|
+
self,
|
|
178
|
+
action: dict,
|
|
179
|
+
screenshot_after: bool = True,
|
|
180
|
+
screenshot_delay_ms: int = 100,
|
|
181
|
+
) -> ActResult:
|
|
182
|
+
resp = self._http.post(
|
|
183
|
+
f"{self._prefix}/act",
|
|
184
|
+
json={
|
|
185
|
+
"action": action,
|
|
186
|
+
"screenshot_after": screenshot_after,
|
|
187
|
+
"screenshot_delay_ms": screenshot_delay_ms,
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
resp.raise_for_status()
|
|
191
|
+
if screenshot_after and resp.headers.get("content-type", "").startswith("image/"):
|
|
192
|
+
return ActResult(screenshot=resp.content)
|
|
193
|
+
return ActResult(data=resp.json())
|
|
194
|
+
|
|
195
|
+
def exec_ssh(self, command: str, timeout: int = 120) -> ExecResult:
|
|
196
|
+
resp = self._http.post(f"{self._prefix}/exec", json={"command": command}, timeout=timeout)
|
|
197
|
+
resp.raise_for_status()
|
|
198
|
+
return ExecResult.from_dict(resp.json())
|
|
199
|
+
|
|
200
|
+
def exec_ax(self, command: str, timeout: int = 120) -> ExecResult:
|
|
201
|
+
"""Run a shell command on the VM via cua-server's run_command.
|
|
202
|
+
|
|
203
|
+
Use this *only* when your command needs the macOS Accessibility API
|
|
204
|
+
(AXUIElementCopyAttributeValue, synthetic CGEvent, etc.). The
|
|
205
|
+
responsibility chain through cua-server (`launchd → cua-server →
|
|
206
|
+
bash → command`) lets the system TCC grant on python3.12 actually
|
|
207
|
+
apply, while the SSH-backed `exec_ssh` puts `sshd-keygen-wrapper`
|
|
208
|
+
in the chain and TCC denies AX calls with -25211.
|
|
209
|
+
|
|
210
|
+
Sparser env than `exec_ssh` — set $PATH explicitly inside your
|
|
211
|
+
command if you need brew binaries.
|
|
212
|
+
"""
|
|
213
|
+
resp = self._http.post(
|
|
214
|
+
f"{self._prefix}/exec_ax", json={"command": command}, timeout=timeout
|
|
215
|
+
)
|
|
216
|
+
resp.raise_for_status()
|
|
217
|
+
return ExecResult.from_dict(resp.json())
|
|
218
|
+
|
|
219
|
+
def upload_dir(self, local_dir: str | Path, remote_dir: str) -> None:
|
|
220
|
+
local_dir = Path(local_dir)
|
|
221
|
+
buf = io.BytesIO()
|
|
222
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
223
|
+
for item in local_dir.rglob("*"):
|
|
224
|
+
tar.add(str(item), arcname=str(item.relative_to(local_dir)))
|
|
225
|
+
tmp = f"/tmp/_mmini_upload_{self.sandbox_id[-8:]}.tar.gz"
|
|
226
|
+
self.upload_bytes(buf.getvalue(), tmp)
|
|
227
|
+
self.exec_ssh(f'mkdir -p "{remote_dir}" && tar xzf {tmp} -C "{remote_dir}" && rm -f {tmp}')
|
|
228
|
+
|
|
229
|
+
def download_dir(self, remote_dir: str, local_dir: str | Path) -> None:
|
|
230
|
+
local_dir = Path(local_dir)
|
|
231
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
# List files via exec, then download each
|
|
233
|
+
result = self.exec_ssh(f'find "{remote_dir}" -type f')
|
|
234
|
+
for line in result.stdout.strip().splitlines():
|
|
235
|
+
line = line.strip()
|
|
236
|
+
if not line:
|
|
237
|
+
continue
|
|
238
|
+
rel = line[len(remote_dir) :].lstrip("/")
|
|
239
|
+
local_path = local_dir / rel
|
|
240
|
+
self.download_file(line, local_path)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class IOSSandbox(Sandbox):
|
|
244
|
+
"""iOS simulator sandbox — tap, swipe, hardware buttons, app management."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, sandbox_id: str, http: httpx.Client):
|
|
247
|
+
super().__init__(sandbox_id, SandboxType.IOS, http)
|
|
248
|
+
self.input = Input(http, self._prefix)
|
|
249
|
+
self.apps = Apps(http, self._prefix)
|
|
250
|
+
self.environment = Environment(http, self._prefix)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# Async
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class AsyncSandbox:
|
|
259
|
+
"""Base async sandbox — shared operations that work on both macOS and iOS."""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
sandbox_id: str,
|
|
264
|
+
type: SandboxType,
|
|
265
|
+
http: httpx.AsyncClient,
|
|
266
|
+
*,
|
|
267
|
+
vnc_url: str = "",
|
|
268
|
+
ssh_url: str = "",
|
|
269
|
+
):
|
|
270
|
+
self.sandbox_id = sandbox_id
|
|
271
|
+
self.type = type
|
|
272
|
+
self.vnc_url = vnc_url
|
|
273
|
+
self.ssh_url = ssh_url
|
|
274
|
+
self._http = http
|
|
275
|
+
self._prefix = f"/v1/sandboxes/{sandbox_id}"
|
|
276
|
+
self._keepalive_stop: threading.Event | None = None
|
|
277
|
+
self._keepalive_thread: threading.Thread | None = None
|
|
278
|
+
|
|
279
|
+
self.screenshot = AsyncScreenshot(http, self._prefix)
|
|
280
|
+
self.recording = AsyncRecording(http, self._prefix)
|
|
281
|
+
self.display = AsyncDisplay(http, self._prefix)
|
|
282
|
+
|
|
283
|
+
async def upload(self, local_path: str | Path, remote_path: str) -> None:
|
|
284
|
+
with open(local_path, "rb") as f:
|
|
285
|
+
data = f.read()
|
|
286
|
+
await self.upload_bytes(data, remote_path)
|
|
287
|
+
|
|
288
|
+
async def upload_bytes(self, data: bytes, remote_path: str) -> None:
|
|
289
|
+
resp = await self._http.put(
|
|
290
|
+
f"{self._prefix}/files",
|
|
291
|
+
params={"path": remote_path},
|
|
292
|
+
content=data,
|
|
293
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
294
|
+
)
|
|
295
|
+
resp.raise_for_status()
|
|
296
|
+
|
|
297
|
+
async def download_file(self, remote_path: str, local_path: str | Path) -> None:
|
|
298
|
+
local_path = Path(local_path)
|
|
299
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
resp = await self._http.get(f"{self._prefix}/files", params={"path": remote_path})
|
|
301
|
+
resp.raise_for_status()
|
|
302
|
+
local_path.write_bytes(resp.content)
|
|
303
|
+
|
|
304
|
+
async def start_keepalive(self, interval: float = 30.0) -> None:
|
|
305
|
+
"""Start a background OS thread that pings the gateway every `interval` seconds.
|
|
306
|
+
|
|
307
|
+
Uses a real thread (not an asyncio task) so it fires reliably even when
|
|
308
|
+
the event loop is blocked by sync calls (e.g. sync Anthropic client).
|
|
309
|
+
"""
|
|
310
|
+
self._keepalive_stop, self._keepalive_thread = _start_keepalive_thread(
|
|
311
|
+
self._http, self._prefix, interval
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def stop_keepalive(self) -> None:
|
|
315
|
+
"""Stop the keepalive background thread."""
|
|
316
|
+
_stop_keepalive_thread(self._keepalive_stop, self._keepalive_thread)
|
|
317
|
+
self._keepalive_stop = None
|
|
318
|
+
self._keepalive_thread = None
|
|
319
|
+
|
|
320
|
+
async def close(self):
|
|
321
|
+
await self.stop_keepalive()
|
|
322
|
+
await self._http.delete(f"{self._prefix}")
|
|
323
|
+
|
|
324
|
+
async def __aenter__(self):
|
|
325
|
+
return self
|
|
326
|
+
|
|
327
|
+
async def __aexit__(self, *_):
|
|
328
|
+
await self.close()
|
|
329
|
+
|
|
330
|
+
def __repr__(self):
|
|
331
|
+
return f"AsyncSandbox({self.sandbox_id!r}, type={self.type.value!r})"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class AsyncMacOSSandbox(AsyncSandbox):
|
|
335
|
+
"""Async macOS VM sandbox."""
|
|
336
|
+
|
|
337
|
+
def __init__(
|
|
338
|
+
self,
|
|
339
|
+
sandbox_id: str,
|
|
340
|
+
http: httpx.AsyncClient,
|
|
341
|
+
*,
|
|
342
|
+
vnc_url: str = "",
|
|
343
|
+
ssh_url: str = "",
|
|
344
|
+
vm_ip: str = "",
|
|
345
|
+
host: str = "",
|
|
346
|
+
):
|
|
347
|
+
super().__init__(sandbox_id, SandboxType.MACOS, http, vnc_url=vnc_url, ssh_url=ssh_url)
|
|
348
|
+
self.vm_ip = vm_ip
|
|
349
|
+
self.host = host
|
|
350
|
+
self.mouse = AsyncMouse(http, self._prefix)
|
|
351
|
+
self.keyboard = AsyncKeyboard(http, self._prefix)
|
|
352
|
+
|
|
353
|
+
async def act(
|
|
354
|
+
self,
|
|
355
|
+
action: dict,
|
|
356
|
+
screenshot_after: bool = True,
|
|
357
|
+
screenshot_delay_ms: int = 100,
|
|
358
|
+
) -> ActResult:
|
|
359
|
+
resp = await self._http.post(
|
|
360
|
+
f"{self._prefix}/act",
|
|
361
|
+
json={
|
|
362
|
+
"action": action,
|
|
363
|
+
"screenshot_after": screenshot_after,
|
|
364
|
+
"screenshot_delay_ms": screenshot_delay_ms,
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
resp.raise_for_status()
|
|
368
|
+
if screenshot_after and resp.headers.get("content-type", "").startswith("image/"):
|
|
369
|
+
return ActResult(screenshot=resp.content)
|
|
370
|
+
return ActResult(data=resp.json())
|
|
371
|
+
|
|
372
|
+
async def exec_ssh(self, command: str, timeout: int = 120) -> ExecResult:
|
|
373
|
+
resp = await self._http.post(
|
|
374
|
+
f"{self._prefix}/exec", json={"command": command}, timeout=timeout
|
|
375
|
+
)
|
|
376
|
+
resp.raise_for_status()
|
|
377
|
+
return ExecResult.from_dict(resp.json())
|
|
378
|
+
|
|
379
|
+
async def exec_ax(self, command: str, timeout: int = 120) -> ExecResult:
|
|
380
|
+
"""Async version of exec_ax — see MacOSSandbox.exec_ax for details."""
|
|
381
|
+
resp = await self._http.post(
|
|
382
|
+
f"{self._prefix}/exec_ax", json={"command": command}, timeout=timeout
|
|
383
|
+
)
|
|
384
|
+
resp.raise_for_status()
|
|
385
|
+
return ExecResult.from_dict(resp.json())
|
|
386
|
+
|
|
387
|
+
async def upload_dir(self, local_dir: str | Path, remote_dir: str) -> None:
|
|
388
|
+
local_dir = Path(local_dir)
|
|
389
|
+
buf = io.BytesIO()
|
|
390
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
391
|
+
for item in local_dir.rglob("*"):
|
|
392
|
+
tar.add(str(item), arcname=str(item.relative_to(local_dir)))
|
|
393
|
+
tmp = f"/tmp/_mmini_upload_{self.sandbox_id[-8:]}.tar.gz"
|
|
394
|
+
await self.upload_bytes(buf.getvalue(), tmp)
|
|
395
|
+
cmd = f'mkdir -p "{remote_dir}" && tar xzf {tmp} -C "{remote_dir}" && rm -f {tmp}'
|
|
396
|
+
await self.exec_ssh(cmd)
|
|
397
|
+
|
|
398
|
+
async def download_dir(self, remote_dir: str, local_dir: str | Path) -> None:
|
|
399
|
+
local_dir = Path(local_dir)
|
|
400
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
401
|
+
# List files via exec, then download each
|
|
402
|
+
result = await self.exec_ssh(f'find "{remote_dir}" -type f')
|
|
403
|
+
for line in result.stdout.strip().splitlines():
|
|
404
|
+
line = line.strip()
|
|
405
|
+
if not line:
|
|
406
|
+
continue
|
|
407
|
+
rel = line[len(remote_dir) :].lstrip("/")
|
|
408
|
+
local_path = local_dir / rel
|
|
409
|
+
await self.download_file(line, local_path)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class AsyncIOSSandbox(AsyncSandbox):
|
|
413
|
+
"""Async iOS simulator sandbox."""
|
|
414
|
+
|
|
415
|
+
def __init__(self, sandbox_id: str, http: httpx.AsyncClient):
|
|
416
|
+
super().__init__(sandbox_id, SandboxType.IOS, http)
|
|
417
|
+
self.input = AsyncInput(http, self._prefix)
|
|
418
|
+
self.apps = AsyncApps(http, self._prefix)
|
|
419
|
+
self.environment = AsyncEnvironment(http, self._prefix)
|