@crosshands/platform-linux 0.1.2

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.
@@ -0,0 +1,1490 @@
1
+ #!/usr/bin/env python3
2
+ """CrossHands Linux computer-use provider.
3
+
4
+ Substantially derived from stablyai/orca at commit
5
+ 9c8f4c398c3f8ba267cca14e0b65c3f6f87f2aa4. Copyright (c) 2026 Lovecast Inc.
6
+ Licensed under the MIT License; see ../../LICENSE and ../../THIRD_PARTY_NOTICES.md.
7
+
8
+ This process speaks newline-delimited JSON over stdin/stdout. It is deliberately
9
+ long lived so AT-SPI references and the most recent bounded snapshot can be
10
+ revalidated without operation files or secrets in command-line arguments.
11
+ """
12
+
13
+ import base64
14
+ import datetime
15
+ import json
16
+ import math
17
+ import os
18
+ import re
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ import uuid
23
+ from dataclasses import dataclass
24
+
25
+
26
+ class PostDispatchError(RuntimeError):
27
+ pass
28
+
29
+
30
+ try:
31
+ import gi
32
+ except ImportError:
33
+ gi = None
34
+
35
+ Atspi = None
36
+ Gdk = None
37
+ GdkPixbuf = None
38
+ GI_IMPORT_ERROR = None
39
+ try:
40
+ if gi is None:
41
+ raise ImportError("Python module 'gi' (PyGObject) is not installed")
42
+ gi.require_version("Atspi", "2.0")
43
+ from gi.repository import Atspi
44
+ except (ImportError, ValueError) as exc:
45
+ GI_IMPORT_ERROR = str(exc)
46
+
47
+ try:
48
+ if gi is None:
49
+ raise ImportError("Python module 'gi' (PyGObject) is not installed")
50
+ gi.require_version("Gdk", "3.0")
51
+ gi.require_version("GdkPixbuf", "2.0")
52
+ from gi.repository import Gdk, GdkPixbuf
53
+ except (ImportError, ValueError):
54
+ Gdk = None
55
+ GdkPixbuf = None
56
+
57
+ MAX_NODES = 1200
58
+ MAX_DEPTH = 64
59
+ TEXT_LIMIT = 500
60
+ MAX_SCREENSHOT_PNG_BYTES = 900_000
61
+ MAX_SCREENSHOT_EDGE = 1280
62
+ MIN_SCREENSHOT_SCALE = 0.25
63
+ SCREENSHOT_SCALE_STEP = 0.85
64
+ BLOCKED_APP_FRAGMENTS = (
65
+ "1password",
66
+ "bitwarden",
67
+ "dashlane",
68
+ "lastpass",
69
+ "nordpass",
70
+ "proton pass",
71
+ )
72
+ CLIPBOARD_COMMAND_TIMEOUT_SECONDS = 2
73
+ CLIPBOARD_OWNER_SETTLE_SECONDS = 0.05
74
+ CLIPBOARD_PASTE_SETTLE_SECONDS = 0.15
75
+ PROVIDER_PROTOCOL = 1
76
+ PUBLIC_CONTRACT = "1.1.0"
77
+ PROVIDER_VERSION = "0.1.0"
78
+ PROVIDER_GENERATION = "linux-" + str(uuid.uuid4())
79
+ SYSTEM_SEARCH_PATH = "/usr/local/bin:/usr/bin:/bin"
80
+ UTILITY_PATHS = {}
81
+ LATEST_SNAPSHOTS = {}
82
+
83
+
84
+ @dataclass
85
+ class Rect:
86
+ x: float
87
+ y: float
88
+ width: float
89
+ height: float
90
+
91
+ def to_json(self):
92
+ return {"x": self.x, "y": self.y, "width": self.width, "height": self.height}
93
+
94
+
95
+ def resolve_utility(name):
96
+ """Resolve only from the provider's fixed system search path."""
97
+ for directory in SYSTEM_SEARCH_PATH.split(":"):
98
+ candidate = os.path.join(directory, name)
99
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
100
+ return os.path.realpath(candidate)
101
+ return None
102
+
103
+
104
+ def utility(name):
105
+ if name not in UTILITY_PATHS:
106
+ UTILITY_PATHS[name] = resolve_utility(name)
107
+ return UTILITY_PATHS[name]
108
+
109
+
110
+ def graphical_session_id():
111
+ return (
112
+ os.environ.get("XDG_SESSION_ID")
113
+ or os.environ.get("WAYLAND_DISPLAY")
114
+ or os.environ.get("DISPLAY")
115
+ or "unavailable"
116
+ )
117
+
118
+
119
+ def session_locked():
120
+ session_id = os.environ.get("XDG_SESSION_ID")
121
+ loginctl = utility("loginctl")
122
+ if not session_id or not loginctl:
123
+ return False
124
+ try:
125
+ result = subprocess.run(
126
+ [loginctl, "show-session", session_id, "--property=LockedHint", "--value"],
127
+ check=False,
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=2,
131
+ env={"PATH": SYSTEM_SEARCH_PATH, "LANG": "C.UTF-8"},
132
+ )
133
+ return result.returncode == 0 and result.stdout.strip().lower() == "yes"
134
+ except (OSError, subprocess.TimeoutExpired):
135
+ return False
136
+
137
+
138
+ def readiness_report():
139
+ session_type = os.environ.get("XDG_SESSION_TYPE", "").strip().lower()
140
+ is_x11 = session_type == "x11"
141
+ is_wayland = session_type == "wayland"
142
+ issues = []
143
+ if GI_IMPORT_ERROR:
144
+ issues.append({
145
+ "code": "missing_python_module",
146
+ "component": "python3-gi/gir1.2-atspi-2.0",
147
+ "message": GI_IMPORT_ERROR,
148
+ })
149
+ if not os.environ.get("DBUS_SESSION_BUS_ADDRESS"):
150
+ issues.append({
151
+ "code": "missing_session_bus",
152
+ "component": "DBUS_SESSION_BUS_ADDRESS",
153
+ "message": "the accessibility session bus is unavailable",
154
+ })
155
+ if not os.environ.get("XDG_RUNTIME_DIR"):
156
+ issues.append({
157
+ "code": "missing_runtime_directory",
158
+ "component": "XDG_RUNTIME_DIR",
159
+ "message": "the active user's runtime directory is unavailable",
160
+ })
161
+ if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
162
+ issues.append({
163
+ "code": "missing_display",
164
+ "component": "DISPLAY/WAYLAND_DISPLAY",
165
+ "message": "no graphical display is attached",
166
+ })
167
+ if not session_type:
168
+ issues.append({
169
+ "code": "unknown_session_type",
170
+ "component": "XDG_SESSION_TYPE",
171
+ "message": "the graphical session type is unavailable",
172
+ })
173
+ if session_locked():
174
+ issues.append({
175
+ "code": "session_locked",
176
+ "component": "logind",
177
+ "message": "the graphical session is locked",
178
+ })
179
+ if is_x11 and utility("xdotool") is None:
180
+ issues.append({
181
+ "code": "missing_x11_utility",
182
+ "component": "xdotool",
183
+ "message": "xdotool is required for X11 hotkeys and window activation",
184
+ })
185
+ clipboard = utility("xclip") or utility("xsel") if is_x11 else utility("wl-copy")
186
+ if clipboard is None:
187
+ issues.append({
188
+ "code": "missing_clipboard_utility",
189
+ "component": "xclip or xsel" if is_x11 else "wl-clipboard",
190
+ "message": "no supported clipboard utility is installed",
191
+ })
192
+ if is_x11 and (Gdk is None or GdkPixbuf is None):
193
+ issues.append({
194
+ "code": "missing_gdk",
195
+ "component": "gir1.2-gtk-3.0",
196
+ "message": "GDK and GdkPixbuf are required for X11 screenshots",
197
+ })
198
+
199
+ hard_codes = {
200
+ "missing_python_module",
201
+ "missing_session_bus",
202
+ "missing_runtime_directory",
203
+ "missing_display",
204
+ "unknown_session_type",
205
+ "session_locked",
206
+ }
207
+ available = not any(issue["code"] in hard_codes for issue in issues)
208
+ return {
209
+ "available": available,
210
+ "sessionType": session_type or "unknown",
211
+ "graphicalSessionId": graphical_session_id(),
212
+ "issues": issues,
213
+ "utilities": {name: utility(name) for name in ("xdotool", "xclip", "xsel", "wl-copy", "wl-paste")},
214
+ "capabilities": {
215
+ "accessibility": available and Atspi is not None,
216
+ "screenshots": available and is_x11 and Gdk is not None and GdkPixbuf is not None,
217
+ "syntheticPointer": available and is_x11,
218
+ "syntheticKeyboard": available and is_x11,
219
+ "hotkey": available and is_x11 and utility("xdotool") is not None,
220
+ "clipboard": available and clipboard is not None,
221
+ "semanticActions": available and Atspi is not None,
222
+ },
223
+ }
224
+
225
+
226
+ def ensure_provider_available(operation=None):
227
+ report = readiness_report()
228
+ if not report["available"]:
229
+ details = "; ".join(issue["message"] for issue in report["issues"])
230
+ raise RuntimeError("provider_unavailable: " + details)
231
+ if operation and not report["capabilities"].get(operation, False):
232
+ raise RuntimeError(
233
+ "unsupported_capability: " + operation + " is unavailable in " + report["sessionType"] + " sessions"
234
+ )
235
+
236
+
237
+ def attempt(fn, fallback=None):
238
+ try:
239
+ value = fn()
240
+ return fallback if value is None else value
241
+ except Exception:
242
+ return fallback
243
+
244
+
245
+ def ensure_desktop_bus():
246
+ if Atspi is None:
247
+ raise RuntimeError(
248
+ "provider_unavailable: PyGObject AT-SPI bindings are unavailable; install python3-gi and gir1.2-atspi-2.0"
249
+ )
250
+ missing = [name for name in ("XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS") if not os.environ.get(name)]
251
+ if missing:
252
+ raise RuntimeError("Linux computer use requires an active desktop session; missing " + ", ".join(missing))
253
+
254
+
255
+ def desktop_root():
256
+ return Atspi.get_desktop(0)
257
+
258
+
259
+ def children(node):
260
+ count = int(attempt(node.get_child_count, 0) or 0)
261
+ for index in range(count):
262
+ child = attempt(lambda i=index: node.get_child_at_index(i))
263
+ if child is not None:
264
+ yield index, child
265
+
266
+
267
+ def text_attr(node, getter):
268
+ return str(attempt(getter, "") or "")
269
+
270
+
271
+ def name_of(node):
272
+ return text_attr(node, node.get_name)
273
+
274
+
275
+ def role_of(node):
276
+ return text_attr(node, node.get_role_name)
277
+
278
+
279
+ def pid_of(node):
280
+ return int(attempt(node.get_process_id, 0) or 0)
281
+
282
+
283
+ def has_state(node, state):
284
+ state_set = attempt(node.get_state_set)
285
+ return bool(state_set is not None and attempt(lambda: state_set.contains(state), False))
286
+
287
+
288
+ def screen_rect(node):
289
+ component = attempt(node.get_component_iface)
290
+ if component is None:
291
+ return None
292
+ rect = attempt(lambda: Atspi.Component.get_extents(component, Atspi.CoordType.SCREEN))
293
+ if rect is None or rect.width <= 0 or rect.height <= 0:
294
+ return None
295
+ return Rect(float(rect.x), float(rect.y), float(rect.width), float(rect.height))
296
+
297
+
298
+ def relative_rect(node, window_rect):
299
+ rect = screen_rect(node)
300
+ if rect is None or window_rect is None:
301
+ return rect
302
+ return Rect(rect.x - window_rect.x, rect.y - window_rect.y, rect.width, rect.height)
303
+
304
+
305
+ def desktop_apps():
306
+ for _, app in children(desktop_root()):
307
+ if name_of(app):
308
+ yield app
309
+
310
+
311
+ def windows_for(app):
312
+ result = []
313
+ for index, child in children(app):
314
+ role = role_of(child).lower()
315
+ rect = screen_rect(child)
316
+ if rect is not None or role in {"frame", "window", "dialog", "alert"}:
317
+ result.append((index, child))
318
+ return result
319
+
320
+
321
+ def choose_window(app, window_id=None, window_index=None):
322
+ windows = windows_for(app)
323
+ if not windows:
324
+ raise RuntimeError("No top-level AT-SPI window is available for " + name_of(app))
325
+ if window_id is not None:
326
+ raise RuntimeError("windowId is not supported by the Linux AT-SPI provider; use windowIndex")
327
+ if window_index is not None:
328
+ for item in windows:
329
+ if item[0] == int(window_index):
330
+ return item
331
+ raise RuntimeError(f'windowNotFound("{window_index}")')
332
+ for item in windows:
333
+ if has_state(item[1], Atspi.StateType.ACTIVE):
334
+ return item
335
+ for item in windows:
336
+ if has_state(item[1], Atspi.StateType.SHOWING):
337
+ return item
338
+ return windows[0]
339
+
340
+
341
+ def restore_window(app, window=None):
342
+ target = window if window is not None else app
343
+ component = attempt(target.get_component_iface)
344
+ if component is not None and attempt(lambda: Atspi.Component.grab_focus(component), False):
345
+ return
346
+ pid = pid_of(app)
347
+ xdotool = utility("xdotool")
348
+ if not pid or not xdotool:
349
+ return
350
+ subprocess.run(
351
+ [xdotool, "search", "--pid", str(pid), "windowactivate", "--sync"],
352
+ check=False,
353
+ stdout=subprocess.DEVNULL,
354
+ stderr=subprocess.DEVNULL,
355
+ env={"PATH": SYSTEM_SEARCH_PATH, "LANG": "C.UTF-8"},
356
+ )
357
+
358
+
359
+ def require_keyboard_focus(window, operation):
360
+ if has_state(window, Atspi.StateType.ACTIVE):
361
+ return
362
+ if operation.get("restoreWindow"):
363
+ deadline = time.monotonic() + 0.5
364
+ while time.monotonic() < deadline:
365
+ if has_state(window, Atspi.StateType.ACTIVE):
366
+ return
367
+ time.sleep(0.05)
368
+ if has_state(window, Atspi.StateType.ACTIVE):
369
+ return
370
+ raise RuntimeError("window_not_focused: keyboard input requires the target window to be focused; restoreWindow was requested but the target window is still not focused; bring it forward manually or check desktop permissions")
371
+ raise RuntimeError("window_not_focused: keyboard input requires the target window to be focused; retry with --restore-window")
372
+
373
+
374
+ def app_matches(app, query):
375
+ needle = str(query or "").strip().lower()
376
+ if not needle:
377
+ return False
378
+ if needle.startswith("pid:"):
379
+ requested_pid = parse_positive_pid(needle[4:])
380
+ return requested_pid is not None and pid_of(app) == requested_pid
381
+ if needle.isdigit() and int(needle) > 0 and pid_of(app) == int(needle):
382
+ return True
383
+ haystacks = [name_of(app).lower()] + [name_of(window).lower() for _, window in windows_for(app)]
384
+ return any(value == needle or needle in value for value in haystacks)
385
+
386
+
387
+ def parse_positive_pid(value):
388
+ return int(value) if value.isdigit() and int(value) > 0 else None
389
+
390
+
391
+ def find_app(query):
392
+ for app in desktop_apps():
393
+ if app_matches(app, query):
394
+ reject_blocked_app(app)
395
+ return app
396
+ raise RuntimeError(f'appNotFound("{query}")')
397
+
398
+
399
+ def assert_expected_process_identity(app, expected):
400
+ if not expected:
401
+ return
402
+ actual_pid = pid_of(app)
403
+ if actual_pid != int(expected.get("pid", 0)):
404
+ raise RuntimeError("stale_target: target process changed before dispatch")
405
+ proc = f"/proc/{actual_pid}"
406
+ try:
407
+ executable = os.readlink(f"{proc}/exe")
408
+ executable_stat = os.stat(f"{proc}/exe")
409
+ process_stat = os.stat(proc)
410
+ with open(f"{proc}/stat", "r", encoding="utf-8") as handle:
411
+ fields = handle.read().rsplit(")", 1)[1].strip().split()
412
+ with open("/proc/sys/kernel/random/boot_id", "r", encoding="utf-8") as handle:
413
+ boot_id = handle.read().strip()
414
+ start_ticks = fields[19]
415
+ started_at = datetime.datetime.fromtimestamp(
416
+ process_stat.st_ctime, datetime.timezone.utc
417
+ ).isoformat(timespec="milliseconds").replace("+00:00", "Z")
418
+ executable_id = (
419
+ f"{executable}:{executable_stat.st_dev}:{executable_stat.st_ino}:"
420
+ f"{boot_id}:{start_ticks}"
421
+ )
422
+ except (OSError, IndexError, ValueError) as error:
423
+ raise RuntimeError("stale_target: target process identity is unavailable") from error
424
+ if (
425
+ started_at != expected.get("startedAt")
426
+ or executable_id != expected.get("executableId")
427
+ ):
428
+ raise RuntimeError("stale_target: target process changed before dispatch")
429
+
430
+
431
+ def reject_blocked_app(app):
432
+ haystacks = [name_of(app).lower()] + [name_of(window).lower() for _, window in windows_for(app)]
433
+ if any(fragment in value for fragment in BLOCKED_APP_FRAGMENTS for value in haystacks):
434
+ raise RuntimeError(f'appBlocked("{name_of(app)}")')
435
+
436
+
437
+ def action_labels(node):
438
+ labels = []
439
+ count = int(attempt(node.get_n_actions, 0) or 0)
440
+ for index in range(count):
441
+ label = str(attempt(lambda i=index: node.get_action_name(i), "") or "")
442
+ description = str(attempt(lambda i=index: node.get_action_description(i), "") or "")
443
+ value = label or description
444
+ if value and value not in labels:
445
+ labels.append(value)
446
+ return labels
447
+
448
+
449
+ def meaningful_actions(actions):
450
+ noisy = {
451
+ "click",
452
+ "press",
453
+ "show default ui",
454
+ "show alternate ui",
455
+ "show menu",
456
+ "scroll to visible",
457
+ "raise",
458
+ }
459
+ return [action for action in actions if action.strip().lower() not in noisy]
460
+
461
+
462
+ def display_action(action):
463
+ value = str(action or "").strip()
464
+ if not value:
465
+ return value
466
+ return " ".join(value.replace("_", " ").split())
467
+
468
+
469
+ def sanitize_text(value):
470
+ return " ".join(str(value or "").replace("\r", " ").replace("\n", " ").split())
471
+
472
+
473
+ def formatted_value(role_key, title, value):
474
+ clean = sanitize_text(value)
475
+ if not clean or clean == title:
476
+ return ""
477
+ if role_key in {"label", "static", "static text", "text", "entry", "text entry"}:
478
+ return " " + clean
479
+ return ", Value: " + clean
480
+
481
+
482
+ def suppress_children(role_key, title, value, summary):
483
+ has_compact_label = bool(title or sanitize_text(value) or sanitize_text(summary))
484
+ return has_compact_label and role_key in {
485
+ "button",
486
+ "check box",
487
+ "checkbox",
488
+ "combo box",
489
+ "heading",
490
+ "link",
491
+ "menu item",
492
+ "page tab",
493
+ "push button",
494
+ "radio button",
495
+ }
496
+
497
+
498
+ def text_iface(node):
499
+ return attempt(lambda: node.get_text_iface())
500
+
501
+
502
+ def is_text_node(node):
503
+ result = attempt(lambda: node.is_text())
504
+ return bool(result) if result is not None else text_iface(node) is not None
505
+
506
+
507
+ def string_value(node):
508
+ if is_secure_node(node):
509
+ return "[redacted]"
510
+ if is_text_node(node):
511
+ iface = text_iface(node)
512
+ count = int(attempt(lambda: Atspi.Text.get_character_count(iface), 0) or 0)
513
+ if iface is not None and count > 0:
514
+ value = str(attempt(lambda: Atspi.Text.get_text(iface, 0, min(count, TEXT_LIMIT)), "") or "")
515
+ return value + ("..." if count > TEXT_LIMIT else "")
516
+ value_iface = attempt(node.get_value_iface)
517
+ if value_iface is not None:
518
+ current = attempt(lambda: Atspi.Value.get_current_value(value_iface))
519
+ if current is not None:
520
+ return str(current)
521
+ return ""
522
+
523
+
524
+ def is_secure_node(node):
525
+ role = role_of(node).lower()
526
+ label = " ".join([role, name_of(node).lower(), accessible_id(node).lower()])
527
+ sensitive_terms = ("password", "passcode", "secret", "one-time code", "verification code")
528
+ if any(term in label for term in sensitive_terms) or re.search(r"(^|[^a-z0-9])pin([^a-z0-9]|$)", label):
529
+ return True
530
+ state_set = attempt(node.get_state_set)
531
+ protected_state = getattr(Atspi.StateType, "PROTECTED", None)
532
+ if state_set is not None and protected_state is not None and attempt(lambda: state_set.contains(protected_state), False):
533
+ return True
534
+ return False
535
+
536
+
537
+ def accessible_id(node):
538
+ return str(attempt(node.get_accessible_id, "") or "")
539
+
540
+
541
+ def record(node, index, path, window_rect):
542
+ role = role_of(node)
543
+ rect = relative_rect(node, window_rect)
544
+ return {
545
+ "index": index,
546
+ "runtimeId": path,
547
+ "automationId": accessible_id(node),
548
+ "name": name_of(node),
549
+ "controlType": role,
550
+ "localizedControlType": role,
551
+ "className": str(attempt(node.get_toolkit_name, "") or ""),
552
+ "value": string_value(node),
553
+ "isSelected": has_state(node, Atspi.StateType.SELECTED),
554
+ "nativeWindowHandle": 0,
555
+ "frame": rect.to_json() if rect else None,
556
+ "actions": action_labels(node),
557
+ }
558
+
559
+
560
+ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs=False):
561
+ records = []
562
+ lines = []
563
+ truncation = {"truncated": False, "maxNodes": MAX_NODES, "maxDepth": MAX_DEPTH, "maxDepthReached": False}
564
+
565
+ def text_snippets(node, limit=6, max_depth=3):
566
+ values = []
567
+ seen = set()
568
+
569
+ def collect(candidate, depth):
570
+ if len(values) >= limit or depth > max_depth:
571
+ return
572
+ role = role_of(candidate).lower()
573
+ if role in {"label", "static", "static text", "text", "link"}:
574
+ for raw in (name_of(candidate), string_value(candidate)):
575
+ value = " ".join(str(raw or "").split())
576
+ if value and value not in seen:
577
+ seen.add(value)
578
+ values.append(value[:80])
579
+ if len(values) >= limit:
580
+ return
581
+ for _, child in children(candidate):
582
+ collect(child, depth + 1)
583
+ if len(values) >= limit:
584
+ return
585
+
586
+ collect(node, 0)
587
+ return values
588
+
589
+ def is_plain_text_subtree(node, max_depth=4):
590
+ saw_text = False
591
+ allowed = {"panel", "filler", "unknown", "section", "label", "static", "static text", "text", "link", "image"}
592
+
593
+ def visit(candidate, depth):
594
+ nonlocal saw_text
595
+ if depth > max_depth:
596
+ return False
597
+ role = role_of(candidate).lower()
598
+ if role not in allowed:
599
+ return False
600
+ if role in {"label", "static", "static text", "text", "link"}:
601
+ saw_text = True
602
+ if meaningful_actions(action_labels(candidate)):
603
+ return False
604
+ return all(visit(child, depth + 1) for _, child in children(candidate))
605
+
606
+ return visit(node, 0) and saw_text
607
+
608
+ def should_elide(item, child_count, summary):
609
+ role = (item["controlType"] or "").lower()
610
+ has_text = bool(item["name"] or item["automationId"] or item["value"])
611
+ return role in {"panel", "filler", "unknown", "section"} and not has_text and not meaningful_actions(item["actions"]) and summary is None and child_count <= 1
612
+
613
+ def walk(node, depth, path):
614
+ if len(records) >= MAX_NODES or depth > MAX_DEPTH:
615
+ truncation["truncated"] = True
616
+ if depth > MAX_DEPTH:
617
+ truncation["maxDepthReached"] = True
618
+ return
619
+ item = record(node, len(records), path, window_rect)
620
+ child_items = list(children(node))
621
+ role_key = (item["controlType"] or "").lower()
622
+ summary_values = text_snippets(node, limit=8, max_depth=4)
623
+ generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None
624
+ if should_elide(item, len(child_items), generic_summary):
625
+ for child_index, child in child_items:
626
+ walk(child, depth, path + [child_index])
627
+ return
628
+ records.append(item)
629
+ title = item["name"] or item["automationId"] or ""
630
+ role_label = item["localizedControlType"] or item["controlType"]
631
+ line = f'{item["index"]} {role_label} {sanitize_text(title)}'.rstrip()
632
+ line += formatted_value(role_key, title, item["value"])
633
+ if generic_summary and generic_summary != title:
634
+ line += ", Text: " + sanitize_text(generic_summary)
635
+ elif role_key in {"row", "table row", "list item"}:
636
+ row_summary = " ".join(text_snippets(node))
637
+ if row_summary and row_summary != title:
638
+ line += ", Text: " + sanitize_text(row_summary)
639
+ filtered_actions = meaningful_actions(item["actions"])
640
+ if filtered_actions:
641
+ line += ", Secondary Actions: " + ", ".join(display_action(action) for action in filtered_actions)
642
+ lines.append(("\t" * depth) + line)
643
+ if generic_summary or suppress_children(role_key, title, item["value"], generic_summary):
644
+ return
645
+ child_line_start = len(lines)
646
+ for child_index, child in child_items:
647
+ walk(child, depth + 1, path + [child_index])
648
+ if compact_browser_tabs:
649
+ compact_rendered_browser_tabs(records, lines, child_line_start, depth + 1)
650
+
651
+ walk(root, 0, root_path)
652
+ return records, lines, truncation
653
+
654
+
655
+ def compact_rendered_browser_tabs(records, lines, start_line, depth):
656
+ tab_line_indexes = [
657
+ line_index
658
+ for line_index in range(start_line, len(lines))
659
+ if is_direct_rendered_browser_tab_line(lines[line_index], depth)
660
+ ]
661
+ if len(tab_line_indexes) < 10:
662
+ return
663
+ records_by_index = {int(item["index"]): item for item in records}
664
+ active_line_indexes = {
665
+ line_index
666
+ for line_index in tab_line_indexes
667
+ if is_active_rendered_browser_tab_line(lines[line_index], depth, records_by_index)
668
+ }
669
+ if not active_line_indexes:
670
+ return
671
+
672
+ omitted_record_indexes = set()
673
+ omitted_count = 0
674
+ insertion_index = tab_line_indexes[0]
675
+ for line_index in reversed(tab_line_indexes):
676
+ if line_index in active_line_indexes:
677
+ continue
678
+ record_index = rendered_element_index(lines[line_index], depth)
679
+ if record_index is not None:
680
+ omitted_record_indexes.add(record_index)
681
+ del lines[line_index]
682
+ omitted_count += 1
683
+ if omitted_count <= 0:
684
+ return
685
+ records[:] = [item for item in records if int(item["index"]) not in omitted_record_indexes]
686
+ lines.insert(insertion_index, ("\t" * depth) + f"... {omitted_count} inactive browser tabs omitted")
687
+
688
+
689
+ def is_direct_rendered_browser_tab_line(line, depth):
690
+ indent = "\t" * depth
691
+ if not line.startswith(indent):
692
+ return False
693
+ text = line[len(indent):]
694
+ if text.startswith("\t"):
695
+ return False
696
+ return re.match(r"^\d+ (page tab|tab item|tab)($|[ (,])", text, re.IGNORECASE) is not None
697
+
698
+
699
+ def is_active_rendered_browser_tab_line(line, depth, records_by_index):
700
+ if "(selected" in line:
701
+ return True
702
+ record_index = rendered_element_index(line, depth)
703
+ record = records_by_index.get(record_index)
704
+ if not record:
705
+ return False
706
+ return bool(record.get("isSelected")) or sanitize_text(record.get("value")) == "1"
707
+
708
+
709
+ def is_browser_app(query, app):
710
+ text = f"{query} {name_of(app)}".lower()
711
+ tokens = set(re.split(r"[^a-z0-9]+", text))
712
+ browser_tokens = {
713
+ "arc",
714
+ "brave",
715
+ "chrome",
716
+ "chromium",
717
+ "edge",
718
+ "msedge",
719
+ "firefox",
720
+ "librewolf",
721
+ "opera",
722
+ "vivaldi",
723
+ "zen",
724
+ }
725
+ return not tokens.isdisjoint(browser_tokens)
726
+
727
+
728
+ def rendered_element_index(line, depth):
729
+ text = line[len("\t" * depth):]
730
+ match = re.match(r"^(\d+)", text)
731
+ return int(match.group(1)) if match else None
732
+
733
+
734
+ def capture_png(rect):
735
+ failure = {
736
+ "error": {
737
+ "code": "screenshot_failed",
738
+ "message": "window screenshot capture failed; retry with --no-screenshot or verify the X11 capture dependencies",
739
+ }
740
+ }
741
+ if Gdk is None or GdkPixbuf is None or rect is None or os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland":
742
+ return failure
743
+ screen = Gdk.Screen.get_default()
744
+ root = screen.get_root_window() if screen else None
745
+ if root is None:
746
+ return failure
747
+ pixbuf = Gdk.pixbuf_get_from_window(root, round(rect.x), round(rect.y), max(1, round(rect.width)), max(1, round(rect.height)))
748
+ if pixbuf is None:
749
+ return failure
750
+ return bounded_png_payload(pixbuf)
751
+
752
+
753
+ def png_bytes(pixbuf):
754
+ ok, data = pixbuf.save_to_bufferv("png", [], [])
755
+ return bytes(data) if ok else None
756
+
757
+
758
+ def screenshot_payload(data, width, height, original_width):
759
+ return {
760
+ "base64": base64.b64encode(data).decode("ascii"),
761
+ "width": width,
762
+ "height": height,
763
+ "scale": width / max(1, original_width),
764
+ }
765
+
766
+
767
+ def bounded_png_payload(pixbuf):
768
+ original_width = max(1, pixbuf.get_width())
769
+ original_height = max(1, pixbuf.get_height())
770
+ data = png_bytes(pixbuf)
771
+ if data is None:
772
+ return {
773
+ "error": {
774
+ "code": "screenshot_failed",
775
+ "message": "window screenshot PNG encoding failed; retry with --no-screenshot",
776
+ }
777
+ }
778
+ if len(data) <= MAX_SCREENSHOT_PNG_BYTES:
779
+ return screenshot_payload(data, original_width, original_height, original_width)
780
+
781
+ # Why: screenshots are sent through JSON/stdout; matching macOS bounds keeps
782
+ # large or high-DPI windows from multiplying native, base64, and Node memory.
783
+ scale = min(1.0, MAX_SCREENSHOT_EDGE / max(original_width, original_height))
784
+ while scale >= MIN_SCREENSHOT_SCALE:
785
+ width = max(1, round(original_width * scale))
786
+ height = max(1, round(original_height * scale))
787
+ if width == original_width and height == original_height:
788
+ scale *= SCREENSHOT_SCALE_STEP
789
+ continue
790
+ scaled = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
791
+ if scaled is None:
792
+ scale *= SCREENSHOT_SCALE_STEP
793
+ continue
794
+ candidate = png_bytes(scaled)
795
+ if candidate is not None:
796
+ if len(candidate) <= MAX_SCREENSHOT_PNG_BYTES:
797
+ return screenshot_payload(candidate, width, height, original_width)
798
+ scale *= SCREENSHOT_SCALE_STEP
799
+
800
+ return {
801
+ "error": {
802
+ "code": "screenshot_failed",
803
+ "message": "screenshot exceeded the computer-use payload cap after downscaling; retry with --no-screenshot or target a smaller window",
804
+ }
805
+ }
806
+
807
+
808
+ def first_descendant(root, predicate, max_nodes=MAX_NODES, max_depth=MAX_DEPTH):
809
+ visited_count = 0
810
+ stack = [(root, 0)]
811
+ seen = set()
812
+ while stack and visited_count < max_nodes:
813
+ node, depth = stack.pop()
814
+ identity = id(node)
815
+ if identity in seen:
816
+ continue
817
+ seen.add(identity)
818
+ visited_count += 1
819
+ if predicate(node):
820
+ return node
821
+ if depth >= max_depth:
822
+ continue
823
+ # Why: focused/selection summaries run after the rendered tree is capped;
824
+ # keep them from walking a larger or cyclic AT-SPI tree and timing out.
825
+ child_items = list(children(node))
826
+ for _, child in reversed(child_items):
827
+ stack.append((child, depth + 1))
828
+ return None
829
+
830
+
831
+ def focused_summary(window):
832
+ node = first_descendant(window, lambda candidate: has_state(candidate, Atspi.StateType.FOCUSED))
833
+ if node is None:
834
+ return None
835
+ return (role_of(node) + " " + name_of(node)).strip()
836
+
837
+
838
+ def selected_text(window):
839
+ node = first_descendant(window, lambda candidate: has_state(candidate, Atspi.StateType.FOCUSED) and is_text_node(candidate))
840
+ iface = text_iface(node) if node is not None else None
841
+ selections = attempt(lambda: Atspi.Text.get_text_selections(iface), [])
842
+ if not selections:
843
+ return None
844
+ selection = selections[0]
845
+ return Atspi.Text.get_text(iface, selection.start_offset, selection.end_offset)
846
+
847
+
848
+ def app_json(app):
849
+ return {"name": name_of(app), "bundleIdentifier": name_of(app), "pid": pid_of(app)}
850
+
851
+
852
+ def window_json(app, index, window):
853
+ bounds = screen_rect(window)
854
+ showing = has_state(window, Atspi.StateType.SHOWING)
855
+ return {
856
+ "index": index,
857
+ "app": app_json(app),
858
+ "id": None,
859
+ "title": name_of(window),
860
+ "x": round(bounds.x) if bounds else None,
861
+ "y": round(bounds.y) if bounds else None,
862
+ "width": max(0, round(bounds.width if bounds else 0)),
863
+ "height": max(0, round(bounds.height if bounds else 0)),
864
+ "isMinimized": not showing,
865
+ "isOffscreen": not showing,
866
+ "screenIndex": None,
867
+ "platform": {"backend": "at-spi", "runtimeId": [index], "role": role_of(window)},
868
+ }
869
+
870
+
871
+ def make_snapshot(query, include_screenshot, window_id=None, window_index=None, restore=False):
872
+ app = find_app(query)
873
+ window_index, window = choose_window(app, window_id, window_index)
874
+ if restore:
875
+ restore_window(app, window)
876
+ window_index, window = choose_window(app, window_id, window_index)
877
+ bounds = screen_rect(window)
878
+ records, lines, truncation = render_accessibility_tree(
879
+ window,
880
+ bounds,
881
+ [window_index],
882
+ compact_browser_tabs=is_browser_app(query, app),
883
+ )
884
+ screenshot = capture_png(bounds) if include_screenshot else None
885
+ return {
886
+ "snapshotId": str(uuid.uuid4()),
887
+ "app": app_json(app),
888
+ "windowTitle": name_of(window),
889
+ "windowId": None,
890
+ "windowIndex": window_index,
891
+ "windowBounds": bounds.to_json() if bounds else None,
892
+ "screenshotPngBase64": screenshot.get("base64") if screenshot else None,
893
+ "screenshotWidth": screenshot.get("width") if screenshot else None,
894
+ "screenshotHeight": screenshot.get("height") if screenshot else None,
895
+ "screenshotScale": screenshot.get("scale") if screenshot else None,
896
+ "screenshotError": screenshot.get("error") if screenshot else None,
897
+ "coordinateSpace": "window",
898
+ "truncation": truncation,
899
+ "treeLines": lines,
900
+ "focusedSummary": focused_summary(window),
901
+ "selectedText": selected_text(window),
902
+ "elements": records,
903
+ }
904
+
905
+
906
+ def list_apps_response():
907
+ apps = []
908
+ for app in sorted(desktop_apps(), key=lambda value: (name_of(value).lower(), pid_of(value))):
909
+ if windows_for(app):
910
+ apps.append({"name": name_of(app), "bundleIdentifier": name_of(app), "pid": pid_of(app)})
911
+ return apps
912
+
913
+
914
+ def list_windows_response(query):
915
+ app = find_app(query)
916
+ return {"app": app_json(app), "windows": [window_json(app, index, window) for index, window in windows_for(app)]}
917
+
918
+
919
+ def handshake_response():
920
+ readiness = readiness_report()
921
+ capabilities = readiness["capabilities"]
922
+ return {
923
+ "platform": "linux",
924
+ "provider": "crosshands-computer-use-linux",
925
+ "providerVersion": PROVIDER_VERSION,
926
+ "protocolVersion": PROVIDER_PROTOCOL,
927
+ "publicContract": PUBLIC_CONTRACT,
928
+ "generation": PROVIDER_GENERATION,
929
+ "graphicalSessionId": readiness["graphicalSessionId"],
930
+ "readiness": readiness,
931
+ "supports": {
932
+ "apps": {"list": True, "bundleIds": False, "pids": True},
933
+ "windows": {"list": True, "targetById": False, "targetByIndex": True, "focus": False, "moveResize": False},
934
+ "observation": {"screenshot": capabilities["screenshots"], "annotatedScreenshot": False, "elementFrames": capabilities["accessibility"], "ocr": False},
935
+ "actions": {
936
+ "click": capabilities["semanticActions"] or capabilities["syntheticPointer"],
937
+ "typeText": capabilities["syntheticKeyboard"],
938
+ "pressKey": capabilities["syntheticKeyboard"],
939
+ "hotkey": capabilities["hotkey"],
940
+ "pasteText": capabilities["clipboard"] and capabilities["syntheticKeyboard"],
941
+ "scroll": capabilities["syntheticPointer"],
942
+ "drag": capabilities["syntheticPointer"],
943
+ "setValue": capabilities["semanticActions"],
944
+ "performAction": capabilities["semanticActions"],
945
+ },
946
+ "surfaces": {"menus": False, "dialogs": False, "dock": False, "menubar": False},
947
+ },
948
+ }
949
+
950
+
951
+ def find_by_path(app, path):
952
+ node = app
953
+ for index in path or []:
954
+ node = dict(children(node)).get(int(index))
955
+ if node is None:
956
+ return None
957
+ return node
958
+
959
+
960
+ def all_nodes(root):
961
+ result = []
962
+
963
+ def walk(node):
964
+ if len(result) >= MAX_NODES:
965
+ return
966
+ result.append(node)
967
+ for _, child in children(node):
968
+ walk(child)
969
+
970
+ walk(root)
971
+ return result
972
+
973
+
974
+ def find_element(app, saved):
975
+ if not saved:
976
+ return None
977
+ by_path = find_by_path(app, saved.get("runtimeId"))
978
+ if by_path is not None and same_element_signature(by_path, saved):
979
+ return by_path
980
+ return None
981
+
982
+
983
+ def same_element_signature(node, saved):
984
+ if role_of(node) != str(saved.get("controlType") or ""):
985
+ return False
986
+ if name_of(node) != str(saved.get("name") or ""):
987
+ return False
988
+ if accessible_id(node) != str(saved.get("automationId") or ""):
989
+ return False
990
+ saved_actions = [str(action) for action in saved.get("actions") or []]
991
+ return action_labels(node) == saved_actions
992
+
993
+
994
+ def preferred_action(node):
995
+ if node is None:
996
+ return None
997
+ priority = {"click", "press", "activate", "invoke", "select", "toggle", "open"}
998
+ fallback = None
999
+ for index in range(int(attempt(node.get_n_actions, 0) or 0)):
1000
+ label = str(attempt(lambda i=index: node.get_action_name(i), "") or "").lower()
1001
+ if label in priority:
1002
+ return index
1003
+ if fallback is None and any(term in label for term in ("click", "press", "activate")):
1004
+ fallback = index
1005
+ return fallback
1006
+
1007
+
1008
+ def perform_action(node, index):
1009
+ return bool(index is not None and attempt(lambda: node.do_action(int(index)), False))
1010
+
1011
+
1012
+ def screen_point(window_rect, saved_element=None, x=None, y=None, node=None):
1013
+ rect = screen_rect(node) if node is not None else None
1014
+ if rect is not None:
1015
+ return rect.x + rect.width / 2, rect.y + rect.height / 2
1016
+ if saved_element is not None:
1017
+ raise RuntimeError("stale element frame; run get-app-state again and use a fresh element index")
1018
+ if window_rect is None or x is None or y is None:
1019
+ raise RuntimeError("coordinate action requires a visible window and coordinates")
1020
+ return window_rect.x + float(x), window_rect.y + float(y)
1021
+
1022
+
1023
+ def require_positive_integer(value, name):
1024
+ try:
1025
+ parsed = int(value)
1026
+ except (TypeError, ValueError):
1027
+ raise RuntimeError(f"{name} must be a positive integer")
1028
+ if parsed <= 0:
1029
+ raise RuntimeError(f"{name} must be a positive integer")
1030
+ return parsed
1031
+
1032
+
1033
+ def require_positive_number(value, name):
1034
+ try:
1035
+ parsed = float(value)
1036
+ except (TypeError, ValueError):
1037
+ raise RuntimeError(f"{name} must be a positive number")
1038
+ if not math.isfinite(parsed) or parsed <= 0:
1039
+ raise RuntimeError(f"{name} must be a positive number")
1040
+ return parsed
1041
+
1042
+
1043
+ def require_non_empty_string(value, name):
1044
+ if value is None or str(value) == "":
1045
+ raise RuntimeError(f"{name} is required")
1046
+ return str(value)
1047
+
1048
+
1049
+ MODIFIER_XDOTOOL_NAMES = {
1050
+ "shift": "shift",
1051
+ "ctrl": "ctrl",
1052
+ "control": "ctrl",
1053
+ "cmdorctrl": "ctrl",
1054
+ "commandorcontrol": "ctrl",
1055
+ "alt": "alt",
1056
+ "option": "alt",
1057
+ "meta": "super",
1058
+ "cmd": "super",
1059
+ "command": "super",
1060
+ "super": "super",
1061
+ "win": "super",
1062
+ }
1063
+ XDOTOOL_ENV = {"PATH": SYSTEM_SEARCH_PATH, "LANG": "C.UTF-8"}
1064
+ CLICK_XDOTOOL_BUTTONS = {"left": "1", "right": "3", "middle": "2"}
1065
+
1066
+
1067
+ def click_modifier_keys(modifiers):
1068
+ keys = []
1069
+ for raw in modifiers or []:
1070
+ token = MODIFIER_XDOTOOL_NAMES.get(str(raw).strip().lower())
1071
+ if token is None:
1072
+ raise RuntimeError(f"unsupported modifier: {raw}")
1073
+ keys.append(token)
1074
+ return keys
1075
+
1076
+
1077
+ def normalize_hotkey_spec(raw):
1078
+ parts = [part.strip() for part in str(raw).split("+") if part.strip()]
1079
+ if not parts:
1080
+ raise RuntimeError("unsupported key: empty")
1081
+ return "+".join(MODIFIER_XDOTOOL_NAMES.get(part.lower(), part) for part in parts)
1082
+
1083
+
1084
+ def run_xdotool(*args, check=True):
1085
+ xdotool = utility("xdotool")
1086
+ if xdotool is None:
1087
+ return None
1088
+ return subprocess.run([xdotool, *args], check=check, env=XDOTOOL_ENV)
1089
+
1090
+
1091
+ def click_at(x, y, button, count, modifiers=None):
1092
+ button = (button or "left").lower()
1093
+ buttons = {"left": ("b1p", "b1r"), "right": ("b3p", "b3r"), "middle": ("b2p", "b2r")}
1094
+ if button not in buttons:
1095
+ raise RuntimeError(f"unsupported mouse button: {button}")
1096
+ down, up = buttons[button]
1097
+ modifier_keys = click_modifier_keys(modifiers)
1098
+ click_count = require_positive_integer(1 if count is None else count, "click_count")
1099
+ if not modifier_keys:
1100
+ for _ in range(click_count):
1101
+ Atspi.generate_mouse_event(round(x), round(y), "abs")
1102
+ Atspi.generate_mouse_event(round(x), round(y), down)
1103
+ time.sleep(0.03)
1104
+ Atspi.generate_mouse_event(round(x), round(y), up)
1105
+ return
1106
+ xdotool = utility("xdotool")
1107
+ if xdotool is None:
1108
+ raise RuntimeError("click modifiers require xdotool")
1109
+ args = []
1110
+ for key in modifier_keys:
1111
+ args.extend(("keydown", key))
1112
+ args.extend(
1113
+ (
1114
+ "mousemove",
1115
+ "--sync",
1116
+ str(round(x)),
1117
+ str(round(y)),
1118
+ "click",
1119
+ "--repeat",
1120
+ str(click_count),
1121
+ CLICK_XDOTOOL_BUTTONS[button],
1122
+ )
1123
+ )
1124
+ for key in reversed(modifier_keys):
1125
+ args.extend(("keyup", key))
1126
+ try:
1127
+ subprocess.run([xdotool, *args], check=True, env=XDOTOOL_ENV)
1128
+ except Exception:
1129
+ for key in reversed(modifier_keys):
1130
+ subprocess.run([xdotool, "keyup", key], check=False, env=XDOTOOL_ENV)
1131
+ raise
1132
+
1133
+
1134
+ def scroll_at(x, y, direction, pages):
1135
+ if direction is None or str(direction).strip() == "":
1136
+ raise RuntimeError("direction is required")
1137
+ direction = str(direction).lower()
1138
+ wheel_events = {
1139
+ "up": ("b4p", "b4r"),
1140
+ "down": ("b5p", "b5r"),
1141
+ "left": ("b6p", "b6r"),
1142
+ "right": ("b7p", "b7r"),
1143
+ }
1144
+ if direction not in wheel_events:
1145
+ raise RuntimeError(f"unsupported scroll direction: {direction}")
1146
+ down, up = wheel_events[direction]
1147
+ page_count = max(1, math.ceil(require_positive_number(1 if pages is None else pages, "pages")))
1148
+ for _ in range(page_count):
1149
+ Atspi.generate_mouse_event(round(x), round(y), "abs")
1150
+ Atspi.generate_mouse_event(round(x), round(y), down)
1151
+ time.sleep(0.03)
1152
+ Atspi.generate_mouse_event(round(x), round(y), up)
1153
+
1154
+
1155
+ def drag_between(start, end, duration_ms=240):
1156
+ duration_ms = min(30_000, require_positive_number(duration_ms, "duration_ms"))
1157
+ Atspi.generate_mouse_event(round(start[0]), round(start[1]), "abs")
1158
+ Atspi.generate_mouse_event(round(start[0]), round(start[1]), "b1p")
1159
+ for step in range(1, 13):
1160
+ x = start[0] + (end[0] - start[0]) * step / 12
1161
+ y = start[1] + (end[1] - start[1]) * step / 12
1162
+ Atspi.generate_mouse_event(round(x), round(y), "abs")
1163
+ time.sleep(duration_ms / 12 / 1000)
1164
+ Atspi.generate_mouse_event(round(end[0]), round(end[1]), "b1r")
1165
+
1166
+
1167
+ def key_name(raw):
1168
+ aliases = {
1169
+ "return": "Return", "enter": "Return", "tab": "Tab", "escape": "Escape", "esc": "Escape",
1170
+ "backspace": "BackSpace", "delete": "Delete", "space": "space", "left": "Left", "right": "Right",
1171
+ "up": "Up", "down": "Down", "home": "Home", "end": "End", "insert": "Insert",
1172
+ "pageup": "Page_Up", "page_up": "Page_Up", "pagedown": "Page_Down", "page_down": "Page_Down",
1173
+ }
1174
+ return aliases.get(str(raw).lower(), str(raw))
1175
+
1176
+
1177
+ def press_key(raw):
1178
+ name = key_name(raw)
1179
+ if len(name) == 1:
1180
+ Atspi.generate_keyboard_event(0, name, Atspi.KeySynthType.STRING)
1181
+ return
1182
+ if Gdk is None:
1183
+ raise RuntimeError("GDK is required for non-character key synthesis")
1184
+ Atspi.generate_keyboard_event(Gdk.keyval_from_name(name), None, Atspi.KeySynthType.PRESSRELEASE)
1185
+
1186
+
1187
+ def hotkey(raw):
1188
+ key_spec = normalize_hotkey_spec(raw)
1189
+ ensure_provider_available("hotkey")
1190
+ if run_xdotool("key", key_spec) is not None:
1191
+ return
1192
+ if "+" in key_spec:
1193
+ raise RuntimeError("hotkey combinations require xdotool")
1194
+ press_key(key_spec)
1195
+
1196
+
1197
+ def type_text(value):
1198
+ Atspi.generate_keyboard_event(0, str(value), Atspi.KeySynthType.STRING)
1199
+
1200
+
1201
+ def paste_text(value):
1202
+ text = str(value)
1203
+ previous = read_clipboard()
1204
+ try:
1205
+ write_clipboard(text)
1206
+ hotkey("ctrl+v")
1207
+ # Why: X11 paste consumers may read the selection after the key event
1208
+ # returns, so keep the pasted text as the owner briefly before restore.
1209
+ time.sleep(CLIPBOARD_PASTE_SETTLE_SECONDS)
1210
+ finally:
1211
+ if previous is not None:
1212
+ write_clipboard(previous)
1213
+ else:
1214
+ # Why: if the clipboard had no readable prior value, do not leave
1215
+ # the agent-provided paste text in the user's system clipboard.
1216
+ write_clipboard("")
1217
+
1218
+
1219
+ def read_clipboard():
1220
+ for command in (["wl-paste"], ["xclip", "-selection", "clipboard", "-o"], ["xsel", "--clipboard", "--output"]):
1221
+ executable = utility(command[0])
1222
+ if executable:
1223
+ try:
1224
+ result = subprocess.run(
1225
+ [executable, *command[1:]],
1226
+ check=False,
1227
+ capture_output=True,
1228
+ text=True,
1229
+ timeout=CLIPBOARD_COMMAND_TIMEOUT_SECONDS,
1230
+ env={"PATH": SYSTEM_SEARCH_PATH, "LANG": "C.UTF-8"},
1231
+ )
1232
+ except subprocess.TimeoutExpired:
1233
+ continue
1234
+ if result.returncode == 0:
1235
+ return result.stdout
1236
+ return None
1237
+
1238
+
1239
+ def write_clipboard(value):
1240
+ wl_copy = utility("wl-copy")
1241
+ if wl_copy:
1242
+ subprocess.run(
1243
+ [wl_copy],
1244
+ input=value,
1245
+ check=True,
1246
+ text=True,
1247
+ timeout=CLIPBOARD_COMMAND_TIMEOUT_SECONDS,
1248
+ env={"PATH": SYSTEM_SEARCH_PATH, "LANG": "C.UTF-8"},
1249
+ )
1250
+ return
1251
+ for command in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]):
1252
+ executable = utility(command[0])
1253
+ if not executable:
1254
+ continue
1255
+ process = subprocess.Popen(
1256
+ [executable, *command[1:]],
1257
+ stdin=subprocess.PIPE,
1258
+ stdout=subprocess.DEVNULL,
1259
+ stderr=subprocess.DEVNULL,
1260
+ text=True,
1261
+ env={"PATH": SYSTEM_SEARCH_PATH, "LANG": "C.UTF-8"},
1262
+ )
1263
+ if process.stdin is not None:
1264
+ process.stdin.write(value)
1265
+ process.stdin.close()
1266
+ time.sleep(CLIPBOARD_OWNER_SETTLE_SECONDS)
1267
+ if process.poll() not in (None, 0):
1268
+ raise RuntimeError(f"{command[0]} failed to set clipboard")
1269
+ return
1270
+ raise RuntimeError("paste_text requires wl-copy, xclip, or xsel")
1271
+
1272
+
1273
+ def set_value(node, value):
1274
+ if node is not None and bool(attempt(node.is_editable_text, False)):
1275
+ editable = attempt(node.get_editable_text_iface)
1276
+ if editable is not None and attempt(lambda: Atspi.EditableText.set_text_contents(editable, str(value)), False):
1277
+ return True
1278
+ value_iface = attempt(node.get_value_iface) if node is not None else None
1279
+ if value_iface is not None:
1280
+ return bool(attempt(lambda: Atspi.Value.set_current_value(value_iface, float(value)), False))
1281
+ return False
1282
+
1283
+
1284
+ def run_operation(operation):
1285
+ tool = operation.get("tool")
1286
+ include_screenshot = not bool(operation.get("noScreenshot"))
1287
+ if tool == "handshake":
1288
+ return {"ok": True, "capabilities": handshake_response()}
1289
+ if tool == "list_apps":
1290
+ return {"ok": True, "apps": list_apps_response()}
1291
+ if tool == "list_windows":
1292
+ return {"ok": True, **list_windows_response(operation.get("app", ""))}
1293
+ if tool == "get_app_state":
1294
+ return {
1295
+ "ok": True,
1296
+ "snapshot": make_snapshot(
1297
+ operation.get("app", ""),
1298
+ include_screenshot,
1299
+ operation.get("windowId"),
1300
+ operation.get("windowIndex"),
1301
+ bool(operation.get("restoreWindow")),
1302
+ ),
1303
+ }
1304
+
1305
+ app = find_app(operation.get("app", ""))
1306
+ assert_expected_process_identity(app, operation.get("expectedIdentity"))
1307
+ _, window = choose_window(app, operation.get("windowId"), operation.get("windowIndex"))
1308
+ if operation.get("restoreWindow"):
1309
+ restore_window(app, window)
1310
+ _, window = choose_window(app, operation.get("windowId"), operation.get("windowIndex"))
1311
+ if tool in {"type_text", "press_key", "hotkey", "paste_text"}:
1312
+ require_keyboard_focus(window, operation)
1313
+ bounds = screen_rect(window)
1314
+ saved = operation.get("element")
1315
+ node = find_element(app, saved)
1316
+ from_node = find_element(app, operation.get("fromElement"))
1317
+ to_node = find_element(app, operation.get("toElement"))
1318
+ action = None
1319
+
1320
+ if tool == "click":
1321
+ # Why: agents expect a click into a target app to make the next
1322
+ # keyboard action safe, even when the click uses an accessibility path.
1323
+ restore_window(app, window)
1324
+ preferred = preferred_action(node)
1325
+ click_count = (
1326
+ require_positive_integer(operation.get("click_count"), "click_count")
1327
+ if operation.get("click_count") is not None
1328
+ else 1
1329
+ )
1330
+ modifiers = operation.get("modifiers") or []
1331
+ handled = (
1332
+ not modifiers
1333
+ and operation.get("mouse_button", "left") == "left"
1334
+ and click_count <= 1
1335
+ and perform_action(node, preferred)
1336
+ )
1337
+ if not handled:
1338
+ ensure_provider_available("syntheticPointer")
1339
+ click_at(
1340
+ *screen_point(bounds, saved, operation.get("x"), operation.get("y"), node),
1341
+ operation.get("mouse_button", "left"),
1342
+ click_count,
1343
+ modifiers,
1344
+ )
1345
+ action = {
1346
+ "path": "synthetic",
1347
+ "actionName": None,
1348
+ "fallbackReason": "modifiersRequireSynthetic" if modifiers else "actionUnsupported",
1349
+ }
1350
+ else:
1351
+ labels = action_labels(node)
1352
+ action = {"path": "accessibility", "actionName": labels[preferred] if preferred is not None and preferred < len(labels) else "action", "fallbackReason": None}
1353
+ elif tool == "perform_secondary_action":
1354
+ wanted = str(operation.get("action", "")).lower()
1355
+ for index, label in enumerate(action_labels(node)):
1356
+ if label.lower() == wanted and perform_action(node, index):
1357
+ action = {"path": "accessibility", "actionName": label, "fallbackReason": None}
1358
+ break
1359
+ else:
1360
+ raise RuntimeError(f'{operation.get("action", "")} is not a valid secondary action')
1361
+ elif tool == "scroll":
1362
+ ensure_provider_available("syntheticPointer")
1363
+ # Why: pointer wheel events should land in the requested app window even
1364
+ # when another desktop window is currently foregrounded.
1365
+ restore_window(app, window)
1366
+ scroll_at(*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node), operation.get("direction"), operation.get("pages"))
1367
+ action = {"path": "synthetic", "actionName": "scroll", "fallbackReason": None}
1368
+ elif tool == "drag":
1369
+ ensure_provider_available("syntheticPointer")
1370
+ # Why: pointer drags are synthetic global input, so activate the target
1371
+ # window before using cached coordinates from its accessibility tree.
1372
+ restore_window(app, window)
1373
+ drag_between(
1374
+ screen_point(bounds, operation.get("fromElement"), operation.get("from_x"), operation.get("from_y"), from_node),
1375
+ screen_point(bounds, operation.get("toElement"), operation.get("to_x"), operation.get("to_y"), to_node),
1376
+ operation.get("duration_ms", 240),
1377
+ )
1378
+ action = {"path": "synthetic", "actionName": "drag", "fallbackReason": None}
1379
+ elif tool == "type_text":
1380
+ ensure_provider_available("syntheticKeyboard")
1381
+ type_text(require_non_empty_string(operation.get("text"), "text"))
1382
+ action = {"path": "synthetic", "actionName": "typeText", "fallbackReason": None, "verification": {"state": "unverified", "reason": "synthetic_input"}}
1383
+ elif tool == "press_key":
1384
+ ensure_provider_available("syntheticKeyboard")
1385
+ press_key(require_non_empty_string(operation.get("key"), "key"))
1386
+ action = {"path": "synthetic", "actionName": "pressKey", "fallbackReason": None, "verification": {"state": "unverified", "reason": "synthetic_input"}}
1387
+ elif tool == "hotkey":
1388
+ hotkey(require_non_empty_string(operation.get("key"), "key"))
1389
+ action = {"path": "synthetic", "actionName": "hotkey", "fallbackReason": None, "verification": {"state": "unverified", "reason": "synthetic_input"}}
1390
+ elif tool == "paste_text":
1391
+ paste_text(require_non_empty_string(operation.get("text"), "text"))
1392
+ action = {"path": "clipboard", "actionName": "paste", "fallbackReason": None, "verification": {"state": "unverified", "reason": "clipboard_paste"}}
1393
+ elif tool == "set_value":
1394
+ if not set_value(node, operation.get("value", "")):
1395
+ raise RuntimeError("element value is not settable")
1396
+ action = {"path": "accessibility", "actionName": "setValue", "fallbackReason": None}
1397
+ else:
1398
+ raise RuntimeError("unknown tool: " + str(tool))
1399
+
1400
+ try:
1401
+ snapshot = make_snapshot(
1402
+ operation.get("app", ""),
1403
+ include_screenshot,
1404
+ operation.get("windowId"),
1405
+ operation.get("windowIndex"),
1406
+ )
1407
+ except Exception as exc:
1408
+ if operation.get("windowId") is None and operation.get("windowIndex") is None:
1409
+ raise PostDispatchError(str(exc)) from exc
1410
+ action.setdefault("verification", {"state": "unverified", "reason": "window_changed"})
1411
+ try:
1412
+ snapshot = make_snapshot(operation.get("app", ""), include_screenshot, None, None)
1413
+ except Exception as fallback_exc:
1414
+ raise PostDispatchError(str(fallback_exc)) from fallback_exc
1415
+
1416
+ return {"ok": True, "action": action, "snapshot": snapshot}
1417
+
1418
+
1419
+ def emit_frame(frame):
1420
+ sys.stdout.write(json.dumps(frame, ensure_ascii=False, separators=(",", ":")) + "\n")
1421
+ sys.stdout.flush()
1422
+
1423
+
1424
+ def native_handshake():
1425
+ capabilities = handshake_response()
1426
+ return {
1427
+ "type": "handshake",
1428
+ "provider": capabilities["provider"],
1429
+ "providerVersion": capabilities["providerVersion"],
1430
+ "providerProtocol": PROVIDER_PROTOCOL,
1431
+ "publicContract": PUBLIC_CONTRACT,
1432
+ "generation": PROVIDER_GENERATION,
1433
+ "graphicalSessionId": capabilities["graphicalSessionId"],
1434
+ "capabilities": capabilities,
1435
+ }
1436
+
1437
+
1438
+ def handle_frame(frame):
1439
+ frame_type = frame.get("type")
1440
+ if frame_type == "cancel":
1441
+ return {"type": "cancelled", "requestId": str(frame.get("requestId") or "")}
1442
+ if frame_type != "request":
1443
+ raise RuntimeError("invalid_argument: expected a request or cancel frame")
1444
+ request_id = str(frame.get("requestId") or "")
1445
+ if not request_id:
1446
+ raise RuntimeError("invalid_argument: requestId is required")
1447
+ operation = frame.get("operation")
1448
+ if not isinstance(operation, dict):
1449
+ raise RuntimeError("invalid_argument: operation must be an object")
1450
+ try:
1451
+ if operation.get("tool") != "handshake":
1452
+ ensure_provider_available()
1453
+ result = run_operation(operation)
1454
+ return {"type": "response", "requestId": request_id, "ok": True, "result": result}
1455
+ except Exception as exc:
1456
+ return {
1457
+ "type": "response",
1458
+ "requestId": request_id,
1459
+ "ok": False,
1460
+ "error": str(exc),
1461
+ "dispatched": isinstance(exc, PostDispatchError),
1462
+ }
1463
+
1464
+
1465
+ def main():
1466
+ # stdout is protocol-only. Diagnostics are returned as structured frames.
1467
+ if sys.argv[1:] == ["--probe"]:
1468
+ emit_frame(native_handshake())
1469
+ return
1470
+ if sys.argv[1:]:
1471
+ emit_frame({"type": "fatal", "error": "runtime accepts no operation-file arguments"})
1472
+ raise SystemExit(2)
1473
+
1474
+ emit_frame(native_handshake())
1475
+ for line in sys.stdin:
1476
+ if len(line.encode("utf-8")) > 1_048_576:
1477
+ emit_frame({"type": "fatal", "error": "provider frame exceeds 1048576 bytes"})
1478
+ return
1479
+ try:
1480
+ frame = json.loads(line)
1481
+ if not isinstance(frame, dict):
1482
+ raise ValueError("frame must be an object")
1483
+ emit_frame(handle_frame(frame))
1484
+ except Exception as exc:
1485
+ emit_frame({"type": "fatal", "error": "malformed provider frame: " + str(exc)})
1486
+ return
1487
+
1488
+
1489
+ if __name__ == "__main__":
1490
+ main()