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/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from mmini.client import AsyncMmini, Mmini, RunStatus
|
|
2
|
+
from mmini.errors import MminiError, PlatformNotSupportedError
|
|
3
|
+
from mmini.models import (
|
|
4
|
+
ActionResult,
|
|
5
|
+
ActResult,
|
|
6
|
+
CursorPosition,
|
|
7
|
+
DisplayInfo,
|
|
8
|
+
ExecResult,
|
|
9
|
+
RecordingInfo,
|
|
10
|
+
)
|
|
11
|
+
from mmini.parsers import Action, parse_pyautogui, parse_xdotool
|
|
12
|
+
from mmini.sandbox import (
|
|
13
|
+
AsyncIOSSandbox,
|
|
14
|
+
AsyncMacOSSandbox,
|
|
15
|
+
AsyncSandbox,
|
|
16
|
+
IOSSandbox,
|
|
17
|
+
MacOSSandbox,
|
|
18
|
+
Sandbox,
|
|
19
|
+
SandboxType,
|
|
20
|
+
)
|
|
21
|
+
from mmini.tasks import Task, TasksClient, TaskSummary
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Action",
|
|
25
|
+
"ActionResult",
|
|
26
|
+
"ActResult",
|
|
27
|
+
"AsyncIOSSandbox",
|
|
28
|
+
"AsyncMacOSSandbox",
|
|
29
|
+
"AsyncMmini",
|
|
30
|
+
"AsyncSandbox",
|
|
31
|
+
"CursorPosition",
|
|
32
|
+
"DisplayInfo",
|
|
33
|
+
"ExecResult",
|
|
34
|
+
"IOSSandbox",
|
|
35
|
+
"MacOSSandbox",
|
|
36
|
+
"Mmini",
|
|
37
|
+
"MminiError",
|
|
38
|
+
"PlatformNotSupportedError",
|
|
39
|
+
"RecordingInfo",
|
|
40
|
+
"RunStatus",
|
|
41
|
+
"Sandbox",
|
|
42
|
+
"SandboxType",
|
|
43
|
+
"Task",
|
|
44
|
+
"TaskSummary",
|
|
45
|
+
"TasksClient",
|
|
46
|
+
"parse_pyautogui",
|
|
47
|
+
"parse_xdotool",
|
|
48
|
+
]
|
mmini/ax_transpile.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rewrite `osascript -e 'tell application "System Events" ...'` patterns into
|
|
3
|
+
shell calls that invoke `/usr/local/bin/ax_helper.py` via cua-server's
|
|
4
|
+
`run_command` endpoint. The helper (baked into base-macos) does the actual
|
|
5
|
+
Accessibility walk with `AXUIElementSetMessagingTimeout` on every element,
|
|
6
|
+
so a wedged target can't alarm-kill the verifier the way inline PyObjC
|
|
7
|
+
could.
|
|
8
|
+
|
|
9
|
+
Why route through cua-server's /cmd run_command? Because that responsibility
|
|
10
|
+
chain (launchd → cua-server → bash → python3.12) is what makes the system
|
|
11
|
+
TCC Accessibility grant on python3.12 actually apply. SSH-backed exec puts
|
|
12
|
+
`sshd-keygen-wrapper` in the chain and TCC denies AX with -25211.
|
|
13
|
+
|
|
14
|
+
Why not inline PyObjC? Previously we emitted base64-wrapped python snippets
|
|
15
|
+
at 5 call sites with no `SetMessagingTimeout`. 441 trials alarm-killed
|
|
16
|
+
because a wedged AX call couldn't be preempted from outside the framework.
|
|
17
|
+
Moving the code into a single baked helper means one place to harden.
|
|
18
|
+
|
|
19
|
+
Coverage
|
|
20
|
+
--------
|
|
21
|
+
Six AppleScript shapes (~100% of the macOSWorld AX-blocked verifier
|
|
22
|
+
patterns):
|
|
23
|
+
|
|
24
|
+
1. `attribute "AX..." of <PATH>` — attr read, path walked from frontmost
|
|
25
|
+
or a named process.
|
|
26
|
+
2. `attributes of <PATH>` — dump all attribute name=value pairs.
|
|
27
|
+
3. `tell process "X" to get value of <ELEMENT> of <PATH>` — AXValue of a
|
|
28
|
+
leaf inside a process.
|
|
29
|
+
4. `get name of first process whose frontmost is true`
|
|
30
|
+
5. `name of every UI element of list 1 of application process "Dock"`
|
|
31
|
+
6. `keystroke "X" [using {modifier list}]` — synthesize CGEvent.
|
|
32
|
+
|
|
33
|
+
Lines that don't match any recognized shape are passed through unchanged.
|
|
34
|
+
|
|
35
|
+
What this does NOT handle
|
|
36
|
+
-------------------------
|
|
37
|
+
* AppleScript with conditionals or repeats
|
|
38
|
+
* `set value of` (we only do reads)
|
|
39
|
+
* Multi-character `keystroke` strings (only single chars)
|
|
40
|
+
None of the classified macOSWorld verifiers need these.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import base64
|
|
46
|
+
import json
|
|
47
|
+
import re
|
|
48
|
+
import shlex
|
|
49
|
+
from typing import cast
|
|
50
|
+
|
|
51
|
+
# ---- Pattern parsing ------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
# AppleScript element kind → AX role. Must stay in sync with
|
|
54
|
+
# scripts/images/ax_helper.py::_role_for_kind().
|
|
55
|
+
_ROLE_MAP: dict[str, str | None] = {
|
|
56
|
+
"window": "AXWindow",
|
|
57
|
+
"group": "AXGroup",
|
|
58
|
+
"scroll area": "AXScrollArea",
|
|
59
|
+
"toolbar": "AXToolbar",
|
|
60
|
+
"menu button": "AXMenuButton",
|
|
61
|
+
"button": "AXButton",
|
|
62
|
+
"pop up button": "AXPopUpButton",
|
|
63
|
+
"text field": "AXTextField",
|
|
64
|
+
"text area": "AXTextArea",
|
|
65
|
+
"checkbox": "AXCheckBox",
|
|
66
|
+
"radio button": "AXRadioButton",
|
|
67
|
+
"list": "AXList",
|
|
68
|
+
"row": "AXRow",
|
|
69
|
+
"tab": "AXTab",
|
|
70
|
+
"menu item": "AXMenuItem",
|
|
71
|
+
"static text": "AXStaticText",
|
|
72
|
+
"splitter group": "AXSplitGroup",
|
|
73
|
+
"ui element": None,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Sort kinds by length descending so "menu button" wins over "button".
|
|
77
|
+
_KINDS_SORTED = cast(list[str], sorted(_ROLE_MAP.keys(), key=len, reverse=True))
|
|
78
|
+
_KIND_ALT = "|".join(re.escape(k) for k in _KINDS_SORTED)
|
|
79
|
+
_PATH_SEG_RE = re.compile(
|
|
80
|
+
r"(" + _KIND_ALT + r')\s+(?:"([^"]+)"|(\d+))',
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Match a full osascript command: one or more `-e '...'` arguments.
|
|
85
|
+
# Handles both bare single quotes and the bash-escape `'\''...'\'']` form
|
|
86
|
+
# that test.sh files use to embed osascript inside `bash -c '...'`.
|
|
87
|
+
#
|
|
88
|
+
# Group 1 captures the FIRST script body. If there are additional `-e '...'`
|
|
89
|
+
# arguments (group 2 present), `transpile()` combines all bodies and uses the
|
|
90
|
+
# fallback timeout wrapper (specialized converters only match single-script patterns).
|
|
91
|
+
_OSASCRIPT_RE = re.compile(
|
|
92
|
+
r"""osascript(?:\s+-e\s+(?:'\\''|')(.*?)(?:'\\''|'))+""",
|
|
93
|
+
re.DOTALL,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Used to extract ALL -e '...' bodies from a matched osascript command.
|
|
97
|
+
_OSASCRIPT_E_RE = re.compile(
|
|
98
|
+
r"""-e\s+(?:'\\''|')(.*?)(?:'\\''|')""",
|
|
99
|
+
re.DOTALL,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_path(path_str: str) -> tuple[list[dict], dict] | None:
|
|
104
|
+
"""Parse `... of (first application process whose frontmost is true)` or
|
|
105
|
+
`... of process "X"` or just `... of window N`.
|
|
106
|
+
|
|
107
|
+
Returns (segments_root_first, root_spec) where root_spec is
|
|
108
|
+
{"root": "frontmost"} or {"root": "process", "name": "X"}. Segments are
|
|
109
|
+
ordered root → leaf as dicts with keys {"kind", "name"?, "index"?}.
|
|
110
|
+
|
|
111
|
+
Returns None if we can't parse the path.
|
|
112
|
+
"""
|
|
113
|
+
s = path_str.strip()
|
|
114
|
+
|
|
115
|
+
m = re.search(
|
|
116
|
+
r"\(?\s*first\s+application\s+process\s+whose\s+frontmost\s+is\s+true\s*\)?\s*$",
|
|
117
|
+
s,
|
|
118
|
+
re.IGNORECASE,
|
|
119
|
+
)
|
|
120
|
+
if m:
|
|
121
|
+
root: dict = {"root": "frontmost"}
|
|
122
|
+
s = s[: m.start()].rstrip()
|
|
123
|
+
s = re.sub(r"\s+of\s*$", "", s, flags=re.IGNORECASE)
|
|
124
|
+
else:
|
|
125
|
+
m = re.search(
|
|
126
|
+
r'(?:application\s+)?process\s+"([^"]+)"\s*$',
|
|
127
|
+
s,
|
|
128
|
+
re.IGNORECASE,
|
|
129
|
+
)
|
|
130
|
+
if not m:
|
|
131
|
+
return None
|
|
132
|
+
root = {"root": "process", "name": m.group(1)}
|
|
133
|
+
s = s[: m.start()].rstrip()
|
|
134
|
+
s = re.sub(r"\s+of\s*$", "", s, flags=re.IGNORECASE)
|
|
135
|
+
|
|
136
|
+
# AppleScript writes segments leaf-first, separated by " of ". Reverse
|
|
137
|
+
# into root→leaf.
|
|
138
|
+
segs_leaf_first: list[dict] = []
|
|
139
|
+
if s:
|
|
140
|
+
for chunk in re.split(r"\s+of\s+", s):
|
|
141
|
+
chunk = chunk.strip()
|
|
142
|
+
if not chunk:
|
|
143
|
+
continue
|
|
144
|
+
mm = _PATH_SEG_RE.match(chunk)
|
|
145
|
+
if not mm:
|
|
146
|
+
return None
|
|
147
|
+
kind = mm.group(1).lower()
|
|
148
|
+
if kind not in _ROLE_MAP:
|
|
149
|
+
return None
|
|
150
|
+
seg: dict = {"kind": kind}
|
|
151
|
+
if mm.group(2) is not None:
|
|
152
|
+
seg["name"] = mm.group(2)
|
|
153
|
+
elif mm.group(3) is not None:
|
|
154
|
+
seg["index"] = int(mm.group(3))
|
|
155
|
+
segs_leaf_first.append(seg)
|
|
156
|
+
|
|
157
|
+
return list(reversed(segs_leaf_first)), root
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_leaf_path(path_str: str) -> list[dict] | None:
|
|
161
|
+
"""Parse a path inside `tell process "X" to get value of <PATH>` — no
|
|
162
|
+
root suffix, just a leaf-first chain of segments. Returns root→leaf."""
|
|
163
|
+
segs_leaf_first: list[dict] = []
|
|
164
|
+
for chunk in re.split(r"\s+of\s+", path_str.strip()):
|
|
165
|
+
chunk = chunk.strip()
|
|
166
|
+
if not chunk:
|
|
167
|
+
continue
|
|
168
|
+
mm = _PATH_SEG_RE.match(chunk)
|
|
169
|
+
if not mm:
|
|
170
|
+
return None
|
|
171
|
+
kind = mm.group(1).lower()
|
|
172
|
+
if kind not in _ROLE_MAP:
|
|
173
|
+
return None
|
|
174
|
+
seg: dict = {"kind": kind}
|
|
175
|
+
if mm.group(2) is not None:
|
|
176
|
+
seg["name"] = mm.group(2)
|
|
177
|
+
elif mm.group(3) is not None:
|
|
178
|
+
seg["index"] = int(mm.group(3))
|
|
179
|
+
segs_leaf_first.append(seg)
|
|
180
|
+
return list(reversed(segs_leaf_first)) if segs_leaf_first else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---- Emission -------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
HELPER = "/usr/local/bin/ax_helper.py"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _emit_helper_call(op: str, *args: str) -> str:
|
|
189
|
+
"""Emit a shell snippet that POSTs to cua-server's /cmd run_command,
|
|
190
|
+
invokes ax_helper.py, and prints stdout.
|
|
191
|
+
|
|
192
|
+
The whole emission is base64-wrapped and piped through `base64 -d |
|
|
193
|
+
bash` so the replacement contains ZERO single/double quotes in its
|
|
194
|
+
payload surface. Verifier test.sh files wrap osascript calls inside
|
|
195
|
+
`bash -c '...'` — any raw single quote in our replacement would break
|
|
196
|
+
the outer quoting. Base64 alphabet (A-Z/a-z/0-9/+//=) never collides.
|
|
197
|
+
"""
|
|
198
|
+
argv = [HELPER, op, *args]
|
|
199
|
+
cmd = " ".join(shlex.quote(a) for a in argv)
|
|
200
|
+
body = json.dumps({"command": "run_command", "params": {"command": cmd}})
|
|
201
|
+
body_b64 = base64.b64encode(body.encode()).decode()
|
|
202
|
+
|
|
203
|
+
# Payload executed by bash after decoding.
|
|
204
|
+
#
|
|
205
|
+
# Critical: do NOT use `python3 <<HEREDOC` to pass the parser source —
|
|
206
|
+
# heredoc input hijacks python's stdin, so the curl pipe goes nowhere
|
|
207
|
+
# and `sys.stdin.read()` returns empty (or the heredoc bytes). Instead
|
|
208
|
+
# we base64-decode the parser at runtime into `python3 -c "$P"`, which
|
|
209
|
+
# leaves python's stdin free for the curl pipe.
|
|
210
|
+
parser_py = (
|
|
211
|
+
"import sys, json\n"
|
|
212
|
+
"raw = sys.stdin.read()\n"
|
|
213
|
+
"for line in raw.splitlines():\n"
|
|
214
|
+
" line = line.strip()\n"
|
|
215
|
+
" if line.startswith('data:'):\n"
|
|
216
|
+
" line = line[5:].strip()\n"
|
|
217
|
+
" if not line:\n"
|
|
218
|
+
" continue\n"
|
|
219
|
+
" try:\n"
|
|
220
|
+
" d = json.loads(line)\n"
|
|
221
|
+
" except Exception:\n"
|
|
222
|
+
" continue\n"
|
|
223
|
+
" print(d.get('stdout', '').rstrip())\n"
|
|
224
|
+
" break\n"
|
|
225
|
+
)
|
|
226
|
+
parser_b64 = base64.b64encode(parser_py.encode()).decode()
|
|
227
|
+
payload = (
|
|
228
|
+
f"B=$(echo {body_b64} | base64 -d); "
|
|
229
|
+
f"P=$(echo {parser_b64} | base64 -d); "
|
|
230
|
+
f"curl -s -m 5 -X POST http://127.0.0.1:8000/cmd "
|
|
231
|
+
f"-H Content-Type:application/json "
|
|
232
|
+
f'--data-raw "$B" 2>/dev/null '
|
|
233
|
+
f'| python3 -c "$P"'
|
|
234
|
+
)
|
|
235
|
+
payload_b64 = base64.b64encode(payload.encode()).decode()
|
|
236
|
+
return f"echo {payload_b64} | base64 -d | bash"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---- Per-shape converters -------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _try_attr_of_path(script: str) -> str | None:
|
|
243
|
+
"""get value of attribute "AX..." of <PATH>"""
|
|
244
|
+
m = re.match(
|
|
245
|
+
r'\s*tell\s+application\s+"System Events"\s+to\s+get\s+value\s+of\s+'
|
|
246
|
+
r'attribute\s+"(AX[A-Za-z]+)"\s+of\s+(.+)\s*$',
|
|
247
|
+
script,
|
|
248
|
+
re.IGNORECASE | re.DOTALL,
|
|
249
|
+
)
|
|
250
|
+
if not m:
|
|
251
|
+
return None
|
|
252
|
+
attr = m.group(1)
|
|
253
|
+
parsed = _parse_path(m.group(2))
|
|
254
|
+
if parsed is None:
|
|
255
|
+
return None
|
|
256
|
+
segs, root = parsed
|
|
257
|
+
path_json = json.dumps([root, *segs])
|
|
258
|
+
return _emit_helper_call("attr", attr, path_json)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _try_attributes_of_path(script: str) -> str | None:
|
|
262
|
+
"""get value of attributes of <PATH>"""
|
|
263
|
+
m = re.match(
|
|
264
|
+
r'\s*tell\s+application\s+"System Events"\s+to\s+get\s+value\s+of\s+'
|
|
265
|
+
r"attributes\s+of\s+(.+)\s*$",
|
|
266
|
+
script,
|
|
267
|
+
re.IGNORECASE | re.DOTALL,
|
|
268
|
+
)
|
|
269
|
+
if not m:
|
|
270
|
+
return None
|
|
271
|
+
parsed = _parse_path(m.group(1))
|
|
272
|
+
if parsed is None:
|
|
273
|
+
return None
|
|
274
|
+
segs, root = parsed
|
|
275
|
+
path_json = json.dumps([root, *segs])
|
|
276
|
+
return _emit_helper_call("attrs", path_json)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _try_value_of_named_element(script: str) -> str | None:
|
|
280
|
+
"""tell process "X" to get value of <LEAF> of <PATH>"""
|
|
281
|
+
m = re.match(
|
|
282
|
+
r'\s*tell\s+application\s+"System Events"\s+to\s+tell\s+process\s+'
|
|
283
|
+
r'"([^"]+)"\s+to\s+get\s+value\s+of\s+(.+)\s*$',
|
|
284
|
+
script,
|
|
285
|
+
re.IGNORECASE | re.DOTALL,
|
|
286
|
+
)
|
|
287
|
+
if not m:
|
|
288
|
+
return None
|
|
289
|
+
process = m.group(1)
|
|
290
|
+
segs = _parse_leaf_path(m.group(2))
|
|
291
|
+
if segs is None:
|
|
292
|
+
return None
|
|
293
|
+
return _emit_helper_call("value", process, json.dumps(segs))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _try_front_process_name(script: str) -> str | None:
|
|
297
|
+
"""get name of first process whose frontmost is true"""
|
|
298
|
+
if not re.match(
|
|
299
|
+
r'\s*tell\s+application\s+"System Events"\s+to\s+get\s+name\s+of\s+'
|
|
300
|
+
r"first\s+process\s+whose\s+frontmost\s+is\s+true\s*$",
|
|
301
|
+
script,
|
|
302
|
+
re.IGNORECASE,
|
|
303
|
+
):
|
|
304
|
+
return None
|
|
305
|
+
return _emit_helper_call("frontmost_name")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _try_dock_items(script: str) -> str | None:
|
|
309
|
+
"""set VAR to name of every UI element of list 1 of application process "Dock" """
|
|
310
|
+
if not re.match(
|
|
311
|
+
r'\s*tell\s+application\s+"System Events"\s+to\s+set\s+\w+\s+to\s+'
|
|
312
|
+
r"name\s+of\s+every\s+UI\s+element\s+of\s+list\s+1\s+of\s+application\s+"
|
|
313
|
+
r'process\s+"Dock"\s*$',
|
|
314
|
+
script,
|
|
315
|
+
re.IGNORECASE,
|
|
316
|
+
):
|
|
317
|
+
return None
|
|
318
|
+
return _emit_helper_call("dock_items")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _try_keystroke(script: str) -> str | None:
|
|
322
|
+
"""keystroke "X" [using {modifier list}]. Single-character only."""
|
|
323
|
+
m = re.match(
|
|
324
|
+
r'\s*tell\s+application\s+"System Events"\s+to\s+keystroke\s+'
|
|
325
|
+
r'"([^"]+)"(?:\s+using\s+\{([^}]+)\})?\s*$',
|
|
326
|
+
script,
|
|
327
|
+
re.IGNORECASE,
|
|
328
|
+
)
|
|
329
|
+
if not m:
|
|
330
|
+
return None
|
|
331
|
+
key = m.group(1)
|
|
332
|
+
if len(key) != 1:
|
|
333
|
+
return None
|
|
334
|
+
mods_raw = (m.group(2) or "").lower()
|
|
335
|
+
mods: list[str] = []
|
|
336
|
+
if "command down" in mods_raw or "cmd down" in mods_raw:
|
|
337
|
+
mods.append("cmd")
|
|
338
|
+
if "shift down" in mods_raw:
|
|
339
|
+
mods.append("shift")
|
|
340
|
+
if "option down" in mods_raw or "alt down" in mods_raw:
|
|
341
|
+
mods.append("alt")
|
|
342
|
+
if "control down" in mods_raw or "ctrl down" in mods_raw:
|
|
343
|
+
mods.append("ctrl")
|
|
344
|
+
return _emit_helper_call("keystroke", key, *mods)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# Default timeout applied to every osascript body that isn't otherwise
|
|
348
|
+
# specialised. 5s is well below the outer harbor alarm (28s) and well above
|
|
349
|
+
# the ~1s a responsive Apple Events target needs.
|
|
350
|
+
DEFAULT_OSASCRIPT_TIMEOUT_S = 5
|
|
351
|
+
|
|
352
|
+
# Timeout used when transpiling pre_command lines (seeding VM state). Notes and
|
|
353
|
+
# iWork apps cold-start in 20-30s on a fresh VM; we need state to actually be
|
|
354
|
+
# written, so we use a 45s budget. Fallback emissions route through exec (SSH),
|
|
355
|
+
# not exec_ax, so there is no cua-server 30s hard limit — only the curl
|
|
356
|
+
# max-time (120s) on the client side applies.
|
|
357
|
+
PRE_COMMAND_OSASCRIPT_TIMEOUT_S = 45
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _emit_with_timeout(applescript_body: str, timeout_s: int = DEFAULT_OSASCRIPT_TIMEOUT_S) -> str:
|
|
361
|
+
"""Wrap an arbitrary AppleScript body in `with timeout of N seconds`.
|
|
362
|
+
|
|
363
|
+
Used as the fallback for `osascript -e '<body>'` calls that none of the
|
|
364
|
+
specialised converters matched — the many `tell application "Notes"` /
|
|
365
|
+
"Keynote" / "Contacts" / "Reminders" patterns that hang on fresh VMs
|
|
366
|
+
because the target app waits on iCloud, an unopened document, or a
|
|
367
|
+
missing UI. Live-verified: a wrapped query returns
|
|
368
|
+
`-1712 (AppleEvent timed out)` at exactly N seconds instead of hanging
|
|
369
|
+
past the outer alarm. Caller's `grep -qi 'true'` then falls through to
|
|
370
|
+
score 0 cleanly rather than alarm-killing the trial.
|
|
371
|
+
|
|
372
|
+
Emission is base64-wrapped so it's safe inside any bash-c wrapper; the
|
|
373
|
+
body goes through a single-quoted heredoc ('__AS_EOF__') so embedded
|
|
374
|
+
quotes in the AppleScript pass through verbatim without further escaping.
|
|
375
|
+
"""
|
|
376
|
+
script = (
|
|
377
|
+
f"osascript <<'__AS_EOF__'\n"
|
|
378
|
+
f"with timeout of {timeout_s} seconds\n"
|
|
379
|
+
f"{applescript_body}\n"
|
|
380
|
+
f"end timeout\n"
|
|
381
|
+
f"__AS_EOF__\n"
|
|
382
|
+
)
|
|
383
|
+
script_b64 = base64.b64encode(script.encode()).decode()
|
|
384
|
+
return f"echo {script_b64} | base64 -d | bash"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
_CONVERTERS = (
|
|
388
|
+
_try_attr_of_path,
|
|
389
|
+
_try_attributes_of_path,
|
|
390
|
+
_try_value_of_named_element,
|
|
391
|
+
_try_front_process_name,
|
|
392
|
+
_try_dock_items,
|
|
393
|
+
_try_keystroke,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _applescript_to_shell(
|
|
398
|
+
script: str, fallback_timeout_s: int = DEFAULT_OSASCRIPT_TIMEOUT_S
|
|
399
|
+
) -> str | None:
|
|
400
|
+
"""Try each specialised converter; fall back to timeout-wrapped osascript.
|
|
401
|
+
|
|
402
|
+
`fallback_timeout_s` controls the `with timeout of N seconds` wrapper used
|
|
403
|
+
when no specialised converter matches. Callers transpiling pre_command lines
|
|
404
|
+
should pass PRE_COMMAND_OSASCRIPT_TIMEOUT_S (45s) so state-seeding calls
|
|
405
|
+
don't get killed before the app responds. Verifier calls use the default 5s
|
|
406
|
+
(fail fast = score 0, no alarm-kill).
|
|
407
|
+
"""
|
|
408
|
+
for fn in _CONVERTERS:
|
|
409
|
+
out = fn(script)
|
|
410
|
+
if out is not None:
|
|
411
|
+
return out
|
|
412
|
+
# Terminal fallback: wrap with timeout so the call never hangs forever.
|
|
413
|
+
return _emit_with_timeout(script.strip(), timeout_s=fallback_timeout_s)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---- Public API -----------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def transpile(text: str, fallback_timeout_s: int = DEFAULT_OSASCRIPT_TIMEOUT_S) -> tuple[str, int]:
|
|
420
|
+
"""Rewrite osascript+System Events lines in `text` to ax_helper calls.
|
|
421
|
+
|
|
422
|
+
Returns (rewritten_text, num_substitutions). Lines that don't match a
|
|
423
|
+
known pattern are left untouched.
|
|
424
|
+
|
|
425
|
+
`fallback_timeout_s` sets the `with timeout of N seconds` applied to
|
|
426
|
+
osascript bodies that hit the fallback converter. Pass
|
|
427
|
+
PRE_COMMAND_OSASCRIPT_TIMEOUT_S when transpiling pre_command lines.
|
|
428
|
+
"""
|
|
429
|
+
count = 0
|
|
430
|
+
|
|
431
|
+
def _replace(m: re.Match) -> str:
|
|
432
|
+
nonlocal count
|
|
433
|
+
# Extract ALL -e '...' bodies from the matched osascript command.
|
|
434
|
+
bodies = [body.replace(r"'\''", "'") for body in _OSASCRIPT_E_RE.findall(m.group(0))]
|
|
435
|
+
if not bodies:
|
|
436
|
+
return m.group(0)
|
|
437
|
+
|
|
438
|
+
if len(bodies) == 1:
|
|
439
|
+
# Single -e: try specialized converters, fall back to timeout wrap.
|
|
440
|
+
replacement = _applescript_to_shell(bodies[0], fallback_timeout_s=fallback_timeout_s)
|
|
441
|
+
else:
|
|
442
|
+
# Multiple -e scripts: combine as newline-separated AppleScript and
|
|
443
|
+
# always use the timeout wrapper (specialized converters expect a single
|
|
444
|
+
# pattern and won't match a compound script).
|
|
445
|
+
combined = "\n".join(bodies)
|
|
446
|
+
replacement = _emit_with_timeout(combined, timeout_s=fallback_timeout_s)
|
|
447
|
+
|
|
448
|
+
if replacement is None:
|
|
449
|
+
return m.group(0)
|
|
450
|
+
count += 1
|
|
451
|
+
return replacement
|
|
452
|
+
|
|
453
|
+
return _OSASCRIPT_RE.sub(_replace, text), count
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def needs_rewrite(text: str) -> bool:
|
|
457
|
+
"""Any `osascript -e` invocation gets rewritten now (the fallback
|
|
458
|
+
converter wraps everything with `with timeout of N seconds`)."""
|
|
459
|
+
return bool(_OSASCRIPT_RE.search(text))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
_B64_PAYLOAD_RE = re.compile(r"echo ([A-Za-z0-9+/=]+) \| base64 -d \| bash")
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def patch_curl_timeouts(text: str) -> tuple[str, int]:
|
|
466
|
+
"""Add -m 5 to `curl -s -X POST` calls that are buried inside base64-encoded
|
|
467
|
+
payloads (the ax_helper emission style). Used by mmini._run_setup to harden
|
|
468
|
+
already-baked test.sh files whose payloads were generated before the -m flag
|
|
469
|
+
was added to _emit_helper_call.
|
|
470
|
+
|
|
471
|
+
Returns (patched_text, num_patched).
|
|
472
|
+
"""
|
|
473
|
+
count = 0
|
|
474
|
+
|
|
475
|
+
def _repatch(m: re.Match) -> str:
|
|
476
|
+
nonlocal count
|
|
477
|
+
try:
|
|
478
|
+
decoded = base64.b64decode(m.group(1)).decode()
|
|
479
|
+
except Exception:
|
|
480
|
+
return m.group(0)
|
|
481
|
+
if "curl -s -X POST" not in decoded:
|
|
482
|
+
return m.group(0)
|
|
483
|
+
patched = decoded.replace("curl -s -X POST", "curl -s -m 5 -X POST")
|
|
484
|
+
if patched == decoded:
|
|
485
|
+
return m.group(0)
|
|
486
|
+
count += 1
|
|
487
|
+
new_b64 = base64.b64encode(patched.encode()).decode()
|
|
488
|
+
return f"echo {new_b64} | base64 -d | bash"
|
|
489
|
+
|
|
490
|
+
return _B64_PAYLOAD_RE.sub(_repatch, text), count
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def needs_exec_ax(text: str) -> bool:
|
|
494
|
+
"""Return True if the (possibly transpiled) text contains ax_helper calls.
|
|
495
|
+
|
|
496
|
+
ax_helper calls (emitted by the specialised converters) use cua-server's
|
|
497
|
+
run_command route and MUST go through exec_ax (launchd→cua-server chain
|
|
498
|
+
provides the Accessibility TCC grant).
|
|
499
|
+
|
|
500
|
+
Fallback-transpiled lines (osascript heredocs wrapped with `with timeout`)
|
|
501
|
+
call osascript directly from bash — they do NOT need exec_ax and can run
|
|
502
|
+
through the regular /exec (SSH/lume) endpoint, which has no 30s hard limit.
|
|
503
|
+
"""
|
|
504
|
+
return HELPER in text
|