computeruseprotocol 0.1.0__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.
cup/platforms/web.py ADDED
@@ -0,0 +1,1009 @@
1
+ """
2
+ Web platform adapter for CUP via Chrome DevTools Protocol (CDP).
3
+
4
+ Connects to a Chromium browser running with --remote-debugging-port,
5
+ captures the accessibility tree via Accessibility.getFullAXTree(),
6
+ and optionally discovers WebMCP tools from the page context.
7
+
8
+ Usage:
9
+ # Launch Chrome with debugging enabled:
10
+ chrome --remote-debugging-port=9222
11
+
12
+ # Capture via CLI:
13
+ python -m cup --platform web --compact
14
+
15
+ # Or via API:
16
+ import cup
17
+ text = cup.snapshot("full")
18
+
19
+ Dependencies:
20
+ pip install websocket-client
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import http.client
26
+ import itertools
27
+ import json
28
+ import os
29
+ import threading
30
+ from typing import Any
31
+
32
+ import websocket # websocket-client
33
+
34
+ from cup._base import PlatformAdapter
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # CDP Transport
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _msg_id_lock = threading.Lock()
41
+ _msg_id_counter = itertools.count(1)
42
+
43
+
44
+ def _cdp_get_targets(host: str, port: int) -> list[dict]:
45
+ """Fetch the list of CDP targets (browser tabs) via HTTP."""
46
+ conn = http.client.HTTPConnection(host, port, timeout=5)
47
+ try:
48
+ conn.request("GET", "/json")
49
+ resp = conn.getresponse()
50
+ data = resp.read().decode("utf-8")
51
+ return json.loads(data)
52
+ finally:
53
+ conn.close()
54
+
55
+
56
+ def _cdp_connect(ws_url: str, host: str | None = None) -> websocket.WebSocket:
57
+ """Open a synchronous websocket connection to a CDP target.
58
+
59
+ If *host* is given, the hostname in *ws_url* is replaced so that
60
+ we always connect via the same address used for target discovery
61
+ (avoids slow ``localhost`` DNS lookups on some systems).
62
+ """
63
+ if host:
64
+ # ws://localhost:9222/devtools/... → ws://127.0.0.1:9222/devtools/...
65
+ from urllib.parse import urlparse, urlunparse
66
+
67
+ parts = urlparse(ws_url)
68
+ ws_url = urlunparse(parts._replace(netloc=f"{host}:{parts.port}"))
69
+ ws = websocket.WebSocket()
70
+ ws.settimeout(30)
71
+ ws.connect(ws_url)
72
+ return ws
73
+
74
+
75
+ def _cdp_send(
76
+ ws: websocket.WebSocket,
77
+ method: str,
78
+ params: dict | None = None,
79
+ timeout: float = 30.0,
80
+ ) -> dict:
81
+ """Send a CDP command and wait for the matching response.
82
+
83
+ Discards interleaved CDP event messages while waiting.
84
+ """
85
+ with _msg_id_lock:
86
+ msg_id = next(_msg_id_counter)
87
+
88
+ message: dict[str, Any] = {"id": msg_id, "method": method}
89
+ if params:
90
+ message["params"] = params
91
+
92
+ old_timeout = ws.gettimeout()
93
+ ws.settimeout(timeout)
94
+ try:
95
+ ws.send(json.dumps(message))
96
+ while True:
97
+ raw = ws.recv()
98
+ resp = json.loads(raw)
99
+ if resp.get("id") == msg_id:
100
+ if "error" in resp:
101
+ err = resp["error"]
102
+ raise RuntimeError(f"CDP error {err.get('code')}: {err.get('message')}")
103
+ return resp
104
+ # else: event notification — discard and keep waiting
105
+ finally:
106
+ ws.settimeout(old_timeout)
107
+
108
+
109
+ def _cdp_close(ws: websocket.WebSocket) -> None:
110
+ """Close a CDP websocket connection."""
111
+ try:
112
+ ws.close()
113
+ except Exception:
114
+ pass
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # CDP AX Role → CUP Role mapping
119
+ # ---------------------------------------------------------------------------
120
+
121
+ # Roles that should be skipped entirely (internal browser nodes)
122
+ _SKIP_ROLES = frozenset(
123
+ {
124
+ "InlineTextBox",
125
+ "LineBreak",
126
+ "IframePresentational",
127
+ "none",
128
+ "Ignored",
129
+ "IgnoredRole",
130
+ }
131
+ )
132
+
133
+ # Explicit mapping for CDP roles that don't match CUP names directly.
134
+ # CDP roles not listed here fall through to the lowercase identity check.
135
+ CDP_ROLE_MAP: dict[str, str] = {
136
+ # Document roots
137
+ "RootWebArea": "document",
138
+ "WebArea": "document",
139
+ # Structural / generic
140
+ "GenericContainer": "generic",
141
+ "Iframe": "generic",
142
+ "Div": "generic",
143
+ "Span": "generic",
144
+ "Paragraph": "generic",
145
+ "Pre": "generic",
146
+ "Mark": "generic",
147
+ "Abbr": "generic",
148
+ "Ruby": "generic",
149
+ "Time": "generic",
150
+ "Subscript": "generic",
151
+ "Superscript": "generic",
152
+ "LabelText": "generic",
153
+ "Legend": "generic",
154
+ # Text
155
+ "StaticText": "text",
156
+ # Groups
157
+ "Blockquote": "group",
158
+ "Figcaption": "group",
159
+ "DescriptionListDetail": "group",
160
+ "Details": "group",
161
+ # Lists
162
+ "DescriptionList": "list",
163
+ "DescriptionListTerm": "listitem",
164
+ # CamelCase → lowercase ARIA
165
+ "progressIndicator": "progressbar",
166
+ "spinButton": "spinbutton",
167
+ "tabList": "tablist",
168
+ "tabPanel": "tabpanel",
169
+ "menuItem": "menuitem",
170
+ "menuItemCheckBox": "menuitemcheckbox",
171
+ "menuItemRadio": "menuitemradio",
172
+ "menuBar": "menubar",
173
+ "listItem": "listitem",
174
+ "treeItem": "treeitem",
175
+ "columnHeader": "columnheader",
176
+ "rowHeader": "rowheader",
177
+ "comboBoxGrouping": "combobox",
178
+ "comboBoxMenuButton": "combobox",
179
+ "comboBoxSelect": "combobox",
180
+ "alertDialog": "alertdialog",
181
+ "contentInfo": "contentinfo",
182
+ "radioButton": "radio",
183
+ "scrollBar": "scrollbar",
184
+ # Semantic overrides
185
+ "Summary": "button",
186
+ "Meter": "progressbar",
187
+ "Output": "status",
188
+ "Figure": "figure",
189
+ "Canvas": "img",
190
+ "Video": "generic",
191
+ "Audio": "generic",
192
+ "Section": "generic", # refined to "region" if named
193
+ }
194
+
195
+ # Valid CUP roles (for the identity-check fallback)
196
+ _CUP_ROLES = frozenset(
197
+ {
198
+ "alert",
199
+ "alertdialog",
200
+ "application",
201
+ "article",
202
+ "banner",
203
+ "button",
204
+ "cell",
205
+ "checkbox",
206
+ "columnheader",
207
+ "combobox",
208
+ "complementary",
209
+ "contentinfo",
210
+ "definition",
211
+ "dialog",
212
+ "directory",
213
+ "document",
214
+ "feed",
215
+ "figure",
216
+ "form",
217
+ "generic",
218
+ "grid",
219
+ "gridcell",
220
+ "group",
221
+ "heading",
222
+ "img",
223
+ "link",
224
+ "list",
225
+ "listbox",
226
+ "listitem",
227
+ "log",
228
+ "main",
229
+ "marquee",
230
+ "math",
231
+ "menu",
232
+ "menubar",
233
+ "menuitem",
234
+ "menuitemcheckbox",
235
+ "menuitemradio",
236
+ "meter",
237
+ "navigation",
238
+ "none",
239
+ "note",
240
+ "option",
241
+ "pane",
242
+ "presentation",
243
+ "progressbar",
244
+ "radio",
245
+ "radiogroup",
246
+ "region",
247
+ "row",
248
+ "rowgroup",
249
+ "rowheader",
250
+ "scrollbar",
251
+ "search",
252
+ "searchbox",
253
+ "separator",
254
+ "slider",
255
+ "spinbutton",
256
+ "status",
257
+ "switch",
258
+ "tab",
259
+ "table",
260
+ "tablist",
261
+ "tabpanel",
262
+ "term",
263
+ "text",
264
+ "textbox",
265
+ "timer",
266
+ "toolbar",
267
+ "tooltip",
268
+ "tree",
269
+ "treegrid",
270
+ "treeitem",
271
+ "window",
272
+ }
273
+ )
274
+
275
+ # Roles where text input is expected
276
+ _TEXT_INPUT_ROLES = frozenset(
277
+ {
278
+ "textbox",
279
+ "searchbox",
280
+ "combobox",
281
+ "spinbutton",
282
+ }
283
+ )
284
+
285
+ # Roles that are inherently clickable
286
+ _CLICKABLE_ROLES = frozenset(
287
+ {
288
+ "button",
289
+ "link",
290
+ "menuitem",
291
+ "menuitemcheckbox",
292
+ "menuitemradio",
293
+ "option",
294
+ "tab",
295
+ }
296
+ )
297
+
298
+ # Roles that support selection
299
+ _SELECTABLE_ROLES = frozenset(
300
+ {
301
+ "option",
302
+ "tab",
303
+ "treeitem",
304
+ "listitem",
305
+ "row",
306
+ "cell",
307
+ "gridcell",
308
+ }
309
+ )
310
+
311
+ # Roles that are toggle-like
312
+ _TOGGLE_ROLES = frozenset(
313
+ {
314
+ "checkbox",
315
+ "switch",
316
+ "menuitemcheckbox",
317
+ }
318
+ )
319
+
320
+ # Roles that are range widgets
321
+ _RANGE_ROLES = frozenset(
322
+ {
323
+ "slider",
324
+ "spinbutton",
325
+ "progressbar",
326
+ "scrollbar",
327
+ "meter",
328
+ }
329
+ )
330
+
331
+
332
+ def _map_cdp_role(cdp_role: str, name: str) -> str | None:
333
+ """Map a CDP AX role string to a CUP role, or None to skip."""
334
+ if cdp_role in _SKIP_ROLES:
335
+ return None
336
+
337
+ # Explicit mapping
338
+ cup_role = CDP_ROLE_MAP.get(cdp_role)
339
+ if cup_role is not None:
340
+ # Section with a name becomes "region"
341
+ if cdp_role == "Section" and name:
342
+ return "region"
343
+ return cup_role
344
+
345
+ # Identity check: CDP role lowercased might already be a valid CUP role
346
+ lower = cdp_role.lower()
347
+ if lower in _CUP_ROLES:
348
+ return lower
349
+
350
+ return "generic"
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # State extraction
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ def _extract_states(
359
+ props: dict[str, Any],
360
+ role: str,
361
+ bounds: dict | None,
362
+ viewport_w: int,
363
+ viewport_h: int,
364
+ ) -> list[str]:
365
+ """Derive CUP states from CDP AX properties."""
366
+ states: list[str] = []
367
+
368
+ if props.get("disabled"):
369
+ states.append("disabled")
370
+ if props.get("focused"):
371
+ states.append("focused")
372
+
373
+ # Expanded / collapsed
374
+ expanded = props.get("expanded")
375
+ if expanded is True:
376
+ states.append("expanded")
377
+ elif expanded is False:
378
+ states.append("collapsed")
379
+
380
+ if props.get("selected"):
381
+ states.append("selected")
382
+
383
+ # Checked (can be boolean or string "true"/"mixed")
384
+ checked = props.get("checked")
385
+ if checked is True or checked == "true":
386
+ states.append("checked")
387
+ elif checked == "mixed":
388
+ states.append("mixed")
389
+
390
+ # Pressed (toggle buttons)
391
+ pressed = props.get("pressed")
392
+ if pressed is True or pressed == "true":
393
+ states.append("pressed")
394
+ elif pressed == "mixed":
395
+ states.append("mixed")
396
+
397
+ if props.get("busy"):
398
+ states.append("busy")
399
+ if props.get("modal"):
400
+ states.append("modal")
401
+ if props.get("required"):
402
+ states.append("required")
403
+
404
+ readonly = props.get("readonly")
405
+ if readonly:
406
+ states.append("readonly")
407
+
408
+ # Editable: text-input role that is not readonly
409
+ if role in _TEXT_INPUT_ROLES and not readonly:
410
+ states.append("editable")
411
+
412
+ # Offscreen detection from bounds vs viewport
413
+ if bounds:
414
+ bx, by = bounds["x"], bounds["y"]
415
+ bw, bh = bounds["w"], bounds["h"]
416
+ if (
417
+ bw <= 0
418
+ or bh <= 0
419
+ or bx + bw <= 0
420
+ or by + bh <= 0
421
+ or bx >= viewport_w
422
+ or by >= viewport_h
423
+ ):
424
+ states.append("offscreen")
425
+
426
+ return states
427
+
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # Action derivation
431
+ # ---------------------------------------------------------------------------
432
+
433
+
434
+ def _derive_actions(
435
+ role: str,
436
+ props: dict[str, Any],
437
+ states: list[str],
438
+ ) -> list[str]:
439
+ """Derive CUP actions from node role and properties."""
440
+ actions: list[str] = []
441
+
442
+ if "disabled" in states:
443
+ return actions
444
+
445
+ if role in _CLICKABLE_ROLES:
446
+ actions.append("click")
447
+ actions.append("rightclick")
448
+ actions.append("doubleclick")
449
+
450
+ if role in _TOGGLE_ROLES:
451
+ actions.append("toggle")
452
+
453
+ if role in _SELECTABLE_ROLES and "select" not in actions:
454
+ actions.append("select")
455
+
456
+ if "expanded" in states or "collapsed" in states:
457
+ if "expand" not in actions:
458
+ actions.append("expand")
459
+ actions.append("collapse")
460
+
461
+ if role in _TEXT_INPUT_ROLES and "readonly" not in states:
462
+ actions.append("type")
463
+ actions.append("setvalue")
464
+
465
+ if role in ("slider", "spinbutton"):
466
+ actions.append("increment")
467
+ actions.append("decrement")
468
+
469
+ if role == "scrollbar":
470
+ actions.append("scroll")
471
+
472
+ # Focusable fallback
473
+ if not actions and props.get("focusable"):
474
+ actions.append("focus")
475
+
476
+ return actions
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # Attribute extraction
481
+ # ---------------------------------------------------------------------------
482
+
483
+
484
+ def _extract_attributes(
485
+ props: dict[str, Any],
486
+ role: str,
487
+ ax_node: dict,
488
+ ) -> dict[str, Any]:
489
+ """Extract optional CUP attributes from CDP AX properties."""
490
+ attrs: dict[str, Any] = {}
491
+
492
+ level = props.get("level")
493
+ if level is not None:
494
+ attrs["level"] = int(level)
495
+
496
+ placeholder = props.get("placeholder")
497
+ if placeholder:
498
+ attrs["placeholder"] = str(placeholder)[:200]
499
+
500
+ orientation = props.get("orientation")
501
+ if orientation:
502
+ attrs["orientation"] = str(orientation)
503
+
504
+ # Range values
505
+ if role in _RANGE_ROLES:
506
+ vmin = props.get("valuemin")
507
+ if vmin is not None:
508
+ attrs["valueMin"] = float(vmin)
509
+ vmax = props.get("valuemax")
510
+ if vmax is not None:
511
+ attrs["valueMax"] = float(vmax)
512
+ vnow = props.get("valuetext") or props.get("valuenow")
513
+ if vnow is not None:
514
+ try:
515
+ attrs["valueNow"] = float(vnow)
516
+ except (ValueError, TypeError):
517
+ pass
518
+
519
+ # URL for links
520
+ if role == "link":
521
+ url = props.get("url")
522
+ if url:
523
+ attrs["url"] = str(url)[:500]
524
+
525
+ # Autocomplete
526
+ autocomplete = props.get("autocomplete")
527
+ if autocomplete and autocomplete != "none":
528
+ attrs["autocomplete"] = str(autocomplete)
529
+
530
+ return attrs
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # CUP node builder
535
+ # ---------------------------------------------------------------------------
536
+
537
+
538
+ def _ax_value(field: Any) -> Any:
539
+ """Unpack a CDP AXValue object to its plain value."""
540
+ if isinstance(field, dict):
541
+ return field.get("value")
542
+ return field
543
+
544
+
545
+ def _build_cup_node(
546
+ ax_node: dict,
547
+ id_gen: itertools.count,
548
+ stats: dict,
549
+ viewport_w: int,
550
+ viewport_h: int,
551
+ ) -> dict | None:
552
+ """Convert a single CDP AX node to a CUP node dict."""
553
+ # Role
554
+ cdp_role = _ax_value(ax_node.get("role")) or "generic"
555
+ name = _ax_value(ax_node.get("name")) or ""
556
+ role = _map_cdp_role(cdp_role, name)
557
+ if role is None:
558
+ return None
559
+
560
+ stats["nodes"] += 1
561
+ stats["roles"][cdp_role] = stats["roles"].get(cdp_role, 0) + 1
562
+
563
+ # Name and description
564
+ name = str(name)[:200] if name else ""
565
+ description = str(_ax_value(ax_node.get("description")) or "")[:200]
566
+
567
+ # Value
568
+ raw_value = _ax_value(ax_node.get("value"))
569
+ value_str = str(raw_value)[:200] if raw_value is not None else ""
570
+
571
+ # Properties into a flat dict for easier lookup
572
+ props: dict[str, Any] = {}
573
+ for prop in ax_node.get("properties", []):
574
+ prop_name = prop.get("name", "")
575
+ props[prop_name] = _ax_value(prop.get("value"))
576
+
577
+ # Bounds (from CDP "boundingBox" field if present)
578
+ bounds = None
579
+ bb = ax_node.get("boundingBox")
580
+ if bb:
581
+ bounds = {
582
+ "x": int(bb.get("x", 0)),
583
+ "y": int(bb.get("y", 0)),
584
+ "w": int(bb.get("width", 0)),
585
+ "h": int(bb.get("height", 0)),
586
+ }
587
+
588
+ # States
589
+ states = _extract_states(props, role, bounds, viewport_w, viewport_h)
590
+
591
+ # Actions
592
+ actions = _derive_actions(role, props, states)
593
+
594
+ # Attributes
595
+ attrs = _extract_attributes(props, role, ax_node)
596
+
597
+ # Assemble CUP node
598
+ node: dict[str, Any] = {
599
+ "id": f"e{next(id_gen)}",
600
+ "role": role,
601
+ "name": name,
602
+ }
603
+ if description:
604
+ node["description"] = description
605
+ if value_str and role in (
606
+ "textbox",
607
+ "searchbox",
608
+ "combobox",
609
+ "spinbutton",
610
+ "slider",
611
+ "progressbar",
612
+ "meter",
613
+ "document",
614
+ ):
615
+ node["value"] = value_str
616
+ if bounds:
617
+ node["bounds"] = bounds
618
+ if states:
619
+ node["states"] = states
620
+ if actions:
621
+ node["actions"] = actions
622
+ if attrs:
623
+ node["attributes"] = attrs
624
+
625
+ # Platform extension
626
+ platform_ext: dict[str, Any] = {"cdpRole": cdp_role}
627
+ backend_id = ax_node.get("backendDOMNodeId")
628
+ if backend_id is not None:
629
+ platform_ext["backendDOMNodeId"] = backend_id
630
+ node_id = ax_node.get("nodeId")
631
+ if node_id:
632
+ platform_ext["cdpNodeId"] = node_id
633
+ node["platform"] = {"web": platform_ext}
634
+
635
+ return node
636
+
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # Tree reconstruction from flat CDP AX node list
640
+ # ---------------------------------------------------------------------------
641
+
642
+
643
+ def _build_tree_from_flat(
644
+ ax_nodes: list[dict],
645
+ id_gen: itertools.count,
646
+ stats: dict,
647
+ max_depth: int,
648
+ viewport_w: int,
649
+ viewport_h: int,
650
+ refs: dict,
651
+ ws_url: str | None = None,
652
+ ) -> list[dict]:
653
+ """Convert the flat CDP AX node list into a nested CUP tree.
654
+
655
+ CDP returns nodes with nodeId + childIds references. We build a
656
+ lookup table, then walk from the root to construct the nested structure.
657
+ """
658
+ if not ax_nodes:
659
+ return []
660
+
661
+ # Build nodeId → ax_node lookup
662
+ node_map: dict[str, dict] = {}
663
+ for ax_node in ax_nodes:
664
+ nid = ax_node.get("nodeId", "")
665
+ if nid:
666
+ node_map[nid] = ax_node
667
+
668
+ cup_cache: dict[str, dict | None] = {}
669
+
670
+ def _convert(node_id: str, depth: int) -> dict | None:
671
+ if depth > max_depth:
672
+ return None
673
+ if node_id in cup_cache:
674
+ return cup_cache[node_id]
675
+
676
+ ax_node = node_map.get(node_id)
677
+ if ax_node is None:
678
+ return None
679
+
680
+ # Check if this node should be skipped before building
681
+ cdp_role = _ax_value(ax_node.get("role")) or "generic"
682
+ if cdp_role in _SKIP_ROLES:
683
+ cup_cache[node_id] = None
684
+ # But still convert children — they may promote up
685
+ child_ids = ax_node.get("childIds", [])
686
+ promoted: list[dict] = []
687
+ if child_ids and depth < max_depth:
688
+ for cid in child_ids:
689
+ child = _convert(str(cid), depth)
690
+ if child is None:
691
+ continue
692
+ if "_promoted" in child:
693
+ promoted.extend(child["_promoted"])
694
+ else:
695
+ promoted.append(child)
696
+ # Return promoted children via a sentinel (handled below)
697
+ if promoted:
698
+ cup_cache[node_id] = {"_promoted": promoted}
699
+ return cup_cache[node_id]
700
+
701
+ cup_node = _build_cup_node(ax_node, id_gen, stats, viewport_w, viewport_h)
702
+ if cup_node is None:
703
+ cup_cache[node_id] = None
704
+ return None
705
+
706
+ if ws_url is not None:
707
+ backend_id = ax_node.get("backendDOMNodeId")
708
+ if backend_id is not None:
709
+ refs[cup_node["id"]] = (ws_url, backend_id)
710
+
711
+ stats["max_depth"] = max(stats["max_depth"], depth)
712
+
713
+ # Recurse into children
714
+ child_ids = ax_node.get("childIds", [])
715
+ if child_ids and depth < max_depth:
716
+ children: list[dict] = []
717
+ for cid in child_ids:
718
+ child_result = _convert(str(cid), depth + 1)
719
+ if child_result is None:
720
+ continue
721
+ # Handle promoted children from skipped nodes
722
+ if "_promoted" in child_result:
723
+ children.extend(child_result["_promoted"])
724
+ else:
725
+ children.append(child_result)
726
+ if children:
727
+ cup_node["children"] = children
728
+
729
+ cup_cache[node_id] = cup_node
730
+ return cup_node
731
+
732
+ # Root is the first node (typically RootWebArea)
733
+ root_id = ax_nodes[0].get("nodeId", "")
734
+ root = _convert(root_id, 0)
735
+ if root is None:
736
+ return []
737
+ if "_promoted" in root:
738
+ return root["_promoted"]
739
+ return [root]
740
+
741
+
742
+ # ---------------------------------------------------------------------------
743
+ # WebMCP tool discovery
744
+ # ---------------------------------------------------------------------------
745
+
746
+ _WEBMCP_JS = """\
747
+ (() => {
748
+ try {
749
+ const mc = navigator.modelContext;
750
+ if (!mc) return JSON.stringify([]);
751
+ let tools = [];
752
+ if (typeof mc.getTools === 'function') {
753
+ tools = mc.getTools();
754
+ } else if (mc.tools) {
755
+ tools = Array.from(mc.tools);
756
+ } else if (mc._tools) {
757
+ tools = Array.from(mc._tools);
758
+ }
759
+ return JSON.stringify(
760
+ tools.map(t => ({
761
+ name: t.name || '',
762
+ description: t.description || '',
763
+ inputSchema: t.inputSchema || null,
764
+ annotations: t.annotations || null,
765
+ })).filter(t => t.name)
766
+ );
767
+ } catch (e) {
768
+ return JSON.stringify([]);
769
+ }
770
+ })()
771
+ """
772
+
773
+
774
+ def _extract_webmcp_tools(ws: websocket.WebSocket) -> list[dict]:
775
+ """Extract WebMCP tools from the page via Runtime.evaluate.
776
+
777
+ Returns a list of tool descriptors, or [] if WebMCP is not available.
778
+ Never raises.
779
+ """
780
+ try:
781
+ resp = _cdp_send(
782
+ ws,
783
+ "Runtime.evaluate",
784
+ {
785
+ "expression": _WEBMCP_JS,
786
+ "returnByValue": True,
787
+ "awaitPromise": False,
788
+ },
789
+ timeout=5.0,
790
+ )
791
+
792
+ remote_obj = resp.get("result", {}).get("result", {})
793
+ raw = remote_obj.get("value", "[]")
794
+ tools = json.loads(raw) if isinstance(raw, str) else []
795
+ # Validate structure
796
+ return [t for t in tools if isinstance(t, dict) and t.get("name")]
797
+ except Exception:
798
+ return []
799
+
800
+
801
+ # ---------------------------------------------------------------------------
802
+ # Viewport info
803
+ # ---------------------------------------------------------------------------
804
+
805
+
806
+ def _get_viewport_info(ws: websocket.WebSocket) -> tuple[int, int, float]:
807
+ """Get viewport width, height, and device pixel ratio."""
808
+ try:
809
+ resp = _cdp_send(
810
+ ws,
811
+ "Runtime.evaluate",
812
+ {
813
+ "expression": (
814
+ "JSON.stringify({"
815
+ "w:window.innerWidth,"
816
+ "h:window.innerHeight,"
817
+ "s:window.devicePixelRatio})"
818
+ ),
819
+ "returnByValue": True,
820
+ },
821
+ timeout=5.0,
822
+ )
823
+
824
+ raw = resp.get("result", {}).get("result", {}).get("value", "{}")
825
+ info = json.loads(raw)
826
+ return (
827
+ int(info.get("w", 1920)),
828
+ int(info.get("h", 1080)),
829
+ float(info.get("s", 1.0)),
830
+ )
831
+ except Exception:
832
+ return (1920, 1080, 1.0)
833
+
834
+
835
+ # ---------------------------------------------------------------------------
836
+ # WebAdapter
837
+ # ---------------------------------------------------------------------------
838
+
839
+
840
+ class WebAdapter(PlatformAdapter):
841
+ """CUP adapter for web pages via Chrome DevTools Protocol (CDP).
842
+
843
+ Connects to a Chromium-based browser running with
844
+ ``--remote-debugging-port``. Browser tabs map to CUP's
845
+ "window" concept.
846
+ """
847
+
848
+ def __init__(
849
+ self,
850
+ cdp_host: str | None = None,
851
+ cdp_port: int | None = None,
852
+ ) -> None:
853
+ self._host = cdp_host or os.environ.get("CUP_CDP_HOST", "127.0.0.1")
854
+ self._port = int(cdp_port or os.environ.get("CUP_CDP_PORT", "9222"))
855
+ self._initialized = False
856
+ self._last_tools: list[dict] = []
857
+
858
+ # -- identity ----------------------------------------------------------
859
+
860
+ @property
861
+ def platform_name(self) -> str:
862
+ return "web"
863
+
864
+ # -- lifecycle ---------------------------------------------------------
865
+
866
+ def initialize(self) -> None:
867
+ if self._initialized:
868
+ return
869
+ try:
870
+ targets = _cdp_get_targets(self._host, self._port)
871
+ except Exception as exc:
872
+ raise RuntimeError(
873
+ f"Cannot connect to CDP at {self._host}:{self._port}. "
874
+ f"Launch Chrome with: chrome --remote-debugging-port={self._port}\n"
875
+ f" Error: {exc}"
876
+ ) from exc
877
+ page_targets = [t for t in targets if t.get("type") == "page"]
878
+ if not page_targets:
879
+ raise RuntimeError(
880
+ f"CDP endpoint at {self._host}:{self._port} has no page targets. "
881
+ f"Open at least one tab in the browser."
882
+ )
883
+ self._initialized = True
884
+
885
+ # -- screen ------------------------------------------------------------
886
+
887
+ def get_screen_info(self) -> tuple[int, int, float]:
888
+ """Return viewport dimensions from the active tab."""
889
+ targets = _cdp_get_targets(self._host, self._port)
890
+ page_targets = [t for t in targets if t.get("type") == "page"]
891
+ if not page_targets:
892
+ return (1920, 1080, 1.0)
893
+
894
+ ws = _cdp_connect(page_targets[0]["webSocketDebuggerUrl"], self._host)
895
+ try:
896
+ return _get_viewport_info(ws)
897
+ finally:
898
+ _cdp_close(ws)
899
+
900
+ # -- window enumeration ------------------------------------------------
901
+
902
+ def _page_targets(self) -> list[dict]:
903
+ targets = _cdp_get_targets(self._host, self._port)
904
+ return [t for t in targets if t.get("type") == "page"]
905
+
906
+ def get_foreground_window(self) -> dict[str, Any]:
907
+ page_targets = self._page_targets()
908
+ if not page_targets:
909
+ raise RuntimeError("No browser tabs found")
910
+ t = page_targets[0]
911
+ return {
912
+ "handle": t["webSocketDebuggerUrl"],
913
+ "title": t.get("title", ""),
914
+ "pid": None,
915
+ "bundle_id": None,
916
+ "url": t.get("url", ""),
917
+ }
918
+
919
+ def get_all_windows(self) -> list[dict[str, Any]]:
920
+ return [
921
+ {
922
+ "handle": t["webSocketDebuggerUrl"],
923
+ "title": t.get("title", ""),
924
+ "pid": None,
925
+ "bundle_id": None,
926
+ "url": t.get("url", ""),
927
+ }
928
+ for t in self._page_targets()
929
+ ]
930
+
931
+ # -- window overview ---------------------------------------------------
932
+
933
+ def get_window_list(self) -> list[dict[str, Any]]:
934
+ targets = self._page_targets()
935
+ results = []
936
+ for i, t in enumerate(targets):
937
+ results.append(
938
+ {
939
+ "title": t.get("title", ""),
940
+ "pid": None,
941
+ "bundle_id": None,
942
+ "foreground": i == 0,
943
+ "bounds": None,
944
+ "url": t.get("url", ""),
945
+ }
946
+ )
947
+ return results
948
+
949
+ def get_desktop_window(self) -> dict[str, Any] | None:
950
+ return None # web platform has no desktop concept
951
+
952
+ # -- tree capture ------------------------------------------------------
953
+
954
+ def capture_tree(
955
+ self,
956
+ windows: list[dict[str, Any]],
957
+ *,
958
+ max_depth: int = 999,
959
+ ) -> tuple[list[dict], dict, dict[str, Any]]:
960
+ self.initialize()
961
+ id_gen = itertools.count()
962
+ stats: dict[str, Any] = {"nodes": 0, "max_depth": 0, "roles": {}}
963
+ refs: dict[str, Any] = {}
964
+ tree: list[dict] = []
965
+ all_tools: list[dict] = []
966
+
967
+ for win in windows:
968
+ ws_url = win["handle"]
969
+ ws = _cdp_connect(ws_url, self._host)
970
+ try:
971
+ # Enable required CDP domains
972
+ _cdp_send(ws, "Accessibility.enable")
973
+ _cdp_send(ws, "Runtime.enable")
974
+
975
+ # Get viewport for offscreen detection
976
+ vw, vh, _ = _get_viewport_info(ws)
977
+
978
+ # Get the full AX tree
979
+ result = _cdp_send(ws, "Accessibility.getFullAXTree")
980
+ ax_nodes = result.get("result", {}).get("nodes", [])
981
+
982
+ roots = _build_tree_from_flat(
983
+ ax_nodes,
984
+ id_gen,
985
+ stats,
986
+ max_depth,
987
+ vw,
988
+ vh,
989
+ refs,
990
+ ws_url,
991
+ )
992
+ tree.extend(roots)
993
+
994
+ # Discover WebMCP tools
995
+ tools = _extract_webmcp_tools(ws)
996
+ all_tools.extend(tools)
997
+ except Exception:
998
+ continue
999
+ finally:
1000
+ _cdp_close(ws)
1001
+
1002
+ self._last_tools = all_tools
1003
+ return tree, stats, refs
1004
+
1005
+ # -- WebMCP tools ------------------------------------------------------
1006
+
1007
+ def get_last_tools(self) -> list[dict]:
1008
+ """Return WebMCP tools discovered during the last capture_tree() call."""
1009
+ return self._last_tools