X5Browser 1.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.
Files changed (67) hide show
  1. x5browser/__init__.py +45 -0
  2. x5browser/browser/__init__.py +5 -0
  3. x5browser/browser/actions.py +535 -0
  4. x5browser/browser/api.py +4304 -0
  5. x5browser/browser/console.py +147 -0
  6. x5browser/browser/dialogs.py +73 -0
  7. x5browser/browser/downloads.py +93 -0
  8. x5browser/browser/format.py +92 -0
  9. x5browser/browser/network.py +197 -0
  10. x5browser/browser/observe.py +777 -0
  11. x5browser/browser/permissions.py +100 -0
  12. x5browser/browser/tabs.py +83 -0
  13. x5browser/browser/tools.py +665 -0
  14. x5browser/browser/types.py +57 -0
  15. x5browser/browser/waits.py +90 -0
  16. x5browser/cdp/__init__.py +1 -0
  17. x5browser/cdp/client.py +235 -0
  18. x5browser/cdp/domains.py +96 -0
  19. x5browser/chrome/__init__.py +1 -0
  20. x5browser/chrome/launcher.py +240 -0
  21. x5browser/chrome/profile.py +83 -0
  22. x5browser/config.py +95 -0
  23. x5browser/control/__init__.py +1 -0
  24. x5browser/control/state.py +132 -0
  25. x5browser/defense/__init__.py +13 -0
  26. x5browser/defense/allowlist.py +155 -0
  27. x5browser/defense/allowlist.yaml +69 -0
  28. x5browser/defense/blocklists.py +88 -0
  29. x5browser/defense/flags.py +59 -0
  30. x5browser/defense/heuristics.py +139 -0
  31. x5browser/defense/lists/custom.txt +11 -0
  32. x5browser/defense/manager.py +271 -0
  33. x5browser/defense/network_blocker.py +102 -0
  34. x5browser/defense/policy.json +29 -0
  35. x5browser/defense/profile.py +89 -0
  36. x5browser/defense/selfheal.py +67 -0
  37. x5browser/defense/shadow.py +68 -0
  38. x5browser/defense/site_overrides.yaml +13 -0
  39. x5browser/defense/tab_guard.py +161 -0
  40. x5browser/defense/tree_filter.py +43 -0
  41. x5browser/input/__init__.py +1 -0
  42. x5browser/input/keymap.py +67 -0
  43. x5browser/live/__init__.py +11 -0
  44. x5browser/live/gateway.py +389 -0
  45. x5browser/live/links.py +148 -0
  46. x5browser/live/stack.py +338 -0
  47. x5browser/live/units/x5-cdp-relay.service +13 -0
  48. x5browser/live/units/x5-chrome.service +18 -0
  49. x5browser/live/units/x5-clipboard.service +15 -0
  50. x5browser/live/units/x5-novnc.service +13 -0
  51. x5browser/live/units/x5-vnc.service +15 -0
  52. x5browser/live/units/x5-xvfb.service +12 -0
  53. x5browser/live/web/test.html +209 -0
  54. x5browser/live/web/x5-live.html +193 -0
  55. x5browser/live_browser.py +252 -0
  56. x5browser/logging/__init__.py +1 -0
  57. x5browser/logging/action_log.py +80 -0
  58. x5browser/maintenance.py +165 -0
  59. x5browser/platform.py +66 -0
  60. x5browser/security/__init__.py +1 -0
  61. x5browser/security/policy.py +20 -0
  62. x5browser/task_session.py +238 -0
  63. x5browser-1.1.0.dist-info/METADATA +218 -0
  64. x5browser-1.1.0.dist-info/RECORD +67 -0
  65. x5browser-1.1.0.dist-info/WHEEL +5 -0
  66. x5browser-1.1.0.dist-info/licenses/LICENSE +21 -0
  67. x5browser-1.1.0.dist-info/top_level.txt +1 -0
x5browser/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """X5Browser: single-folder browser library for AI agents.
2
+
3
+ Local package usage (no Docker)::
4
+
5
+ from x5browser import LiveBrowser
6
+
7
+ live = LiveBrowser() # hidden Chrome + live link + timeline
8
+ await live.start() # stack + agent + self-cleaning janitor
9
+ print(live.live_url) # open / embed in any <iframe>
10
+ live.get_tools() # catalog (full=True for all tools)
11
+ await live.navigate("https://example.com") # screenshot per action
12
+ await live.take_control() # user drives the same browser
13
+ await live.agent_control() # agent resumes with fresh observe
14
+ await live.stop_browser() # quiet close, data kept, reopen on demand
15
+ await live.stop()
16
+
17
+ Lower layers stay importable directly (X5Browser + tool catalog,
18
+ ControlManager, LiveStack, Janitor) for projects that wire their own UI.
19
+ """
20
+
21
+ from .browser.api import X5Browser
22
+ from .browser.tools import TOOL_SCHEMAS
23
+ from .browser.types import ActionResult
24
+ from .config import PROJECT_ROOT, BrowserConfig
25
+ from .control.state import ControlManager
26
+ from .live.stack import LiveStack
27
+ from .live_browser import LiveBrowser
28
+ from .maintenance import Janitor
29
+ from .platform import default_profile_dir, detect_host
30
+
31
+ __version__ = "1.1.0"
32
+
33
+ __all__ = [
34
+ "LiveBrowser",
35
+ "X5Browser",
36
+ "ActionResult",
37
+ "BrowserConfig",
38
+ "ControlManager",
39
+ "Janitor",
40
+ "LiveStack",
41
+ "TOOL_SCHEMAS",
42
+ "PROJECT_ROOT",
43
+ "default_profile_dir",
44
+ "detect_host",
45
+ ]
@@ -0,0 +1,5 @@
1
+ """Agent-facing page tools (M1): observation, navigation, interaction, waiting."""
2
+
3
+ from .types import ActionResult, AgentSession, ElementRef, Observation
4
+
5
+ __all__ = ["ActionResult", "AgentSession", "ElementRef", "Observation"]
@@ -0,0 +1,535 @@
1
+ """Real-input page actions (M1, spec 9).
2
+
3
+ Every function sends genuine CDP Input.* events only. No JavaScript is ever
4
+ injected for interaction (spec non-negotiable). Each action resolves the ref
5
+ against the current observation, verifies, acts, then waits stable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .types import AgentSession, ElementRef
13
+ from ..input.keymap import (
14
+ MOD_ALT,
15
+ MOD_CTRL,
16
+ MOD_META,
17
+ MOD_SHIFT,
18
+ MODIFIERS,
19
+ NAMED_KEYS,
20
+ parse_key,
21
+ parse_modifiers,
22
+ )
23
+
24
+
25
+ def resolve_ref(session: AgentSession, ref: int) -> ElementRef:
26
+ """Fetch a ref from the current observation or raise a fix-hinted error."""
27
+ observation = session.observation
28
+ if observation is None or ref not in observation.refs:
29
+ raise ValueError(
30
+ f"error: ref_not_found - ref {ref} not found (page changed). "
31
+ "call page_snapshot()."
32
+ )
33
+ return observation.refs[ref]
34
+
35
+
36
+ def element_session(session: AgentSession, element: ElementRef) -> str:
37
+ """Owning CDP session of an element (frame sessions fall back to main)."""
38
+ return element.session_id or session.session_id
39
+
40
+
41
+ def quad_center(quad: list | None) -> tuple[float, float] | None:
42
+ """Center of a content quad, or None when geometry is unknown."""
43
+ if not quad or len(quad) < 8:
44
+ return None
45
+ xs = quad[0::2]
46
+ ys = quad[1::2]
47
+ return (sum(xs) / len(xs), sum(ys) / len(ys))
48
+
49
+
50
+ async def _mouse(
51
+ client: Any, session_id: str, action: str, x: float, y: float, **extra: Any
52
+ ) -> None:
53
+ params: dict[str, Any] = {"type": action, "x": x, "y": y, **extra}
54
+ await client.send("Input.dispatchMouseEvent", params, session_id=session_id)
55
+
56
+
57
+ async def _key(
58
+ client: Any,
59
+ session_id: str,
60
+ action: str,
61
+ key: str,
62
+ code: str,
63
+ windows_code: int = 0,
64
+ modifiers: int = 0,
65
+ text: str | None = None,
66
+ ) -> None:
67
+ params: dict[str, Any] = {
68
+ "type": action,
69
+ "key": key,
70
+ "code": code,
71
+ "windowsVirtualKeyCode": windows_code,
72
+ "modifiers": modifiers,
73
+ }
74
+ if text is not None:
75
+ params["text"] = text
76
+ await client.send("Input.dispatchKeyEvent", params, session_id=session_id)
77
+
78
+
79
+ async def settle_scroll(client: Any, session_id: str) -> None:
80
+ """Wait until the scroll position stops changing (max ~1s).
81
+
82
+ scrollIntoView settles asynchronously against hit-testing: pressing on
83
+ quads read mid-settle can hit the wrong element (verified), so every
84
+ targeting path settles first.
85
+ """
86
+ last: tuple = ()
87
+ for _ in range(10):
88
+ try:
89
+ metrics = await client.send(
90
+ "Page.getLayoutMetrics", session_id=session_id
91
+ )
92
+ page = metrics.get("layoutViewport", {}) or {}
93
+ position = (page.get("pageX", 0), page.get("pageY", 0))
94
+ except Exception: # noqa: BLE001 - a lost metrics call ends settling
95
+ return
96
+ if position == last:
97
+ return
98
+ last = position
99
+ import asyncio
100
+
101
+ await asyncio.sleep(0.1)
102
+
103
+
104
+ async def element_center(
105
+ session: AgentSession, element: ElementRef
106
+ ) -> tuple[float, float]:
107
+ """Scroll into view, settle, and return the fresh page-space center."""
108
+ target = element_session(session, element)
109
+ center: tuple[float, float] | None = None
110
+ for _ in range(3): # bounded retries (Absolute Rule 20)
111
+ await session.client.send(
112
+ "DOM.scrollIntoViewIfNeeded",
113
+ {"backendNodeId": element.backend_id},
114
+ session_id=target,
115
+ )
116
+ await settle_scroll(session.client, target)
117
+ try:
118
+ result = await session.client.send(
119
+ "DOM.getContentQuads",
120
+ {"backendNodeId": element.backend_id},
121
+ session_id=target,
122
+ )
123
+ quads = result.get("quads", [])
124
+ center = quad_center(list(quads[0])) if quads else None
125
+ except Exception: # noqa: BLE001 - fall back to the observe-time quad
126
+ center = None
127
+ center = center or quad_center(element.quad)
128
+ if center is None:
129
+ break
130
+ owner = await coverage_owner(
131
+ session, center[0] + element.dx, center[1] + element.dy
132
+ )
133
+ if owner is None or await is_same_or_descendant(
134
+ session, element.backend_id, owner
135
+ ) or await is_frame_hit(session, element, owner):
136
+ break
137
+ if center is None:
138
+ raise ValueError(
139
+ f"error: element_not_visible - {element.role} {element.name!r} "
140
+ "has no geometry. call page_snapshot()."
141
+ )
142
+ return (center[0] + element.dx, center[1] + element.dy)
143
+
144
+
145
+ async def is_same_or_descendant(
146
+ session: AgentSession, ancestor_backend: int, node_backend: int
147
+ ) -> bool:
148
+ """True when the hit-tested node is the target or inside it.
149
+
150
+ Hit-testing may return an inner text node instead of the element itself;
151
+ that is a hit, not a cover. Bounded subtree search (depth 5).
152
+ """
153
+ if ancestor_backend == node_backend:
154
+ return True
155
+ return await _backend_contains(
156
+ session, ancestor_backend, node_backend, session.session_id
157
+ )
158
+
159
+
160
+ async def is_frame_hit(
161
+ session: AgentSession, element: ElementRef, node_backend: int
162
+ ) -> bool:
163
+ """True when the hit node is the frame element wrapping a frame target.
164
+
165
+ Main-target hit-testing stops at the iframe element (an ancestor of the
166
+ inner button), which can never occlude its own content. Accepted only
167
+ for frame elements and only when the owner is a genuine ancestor.
168
+ """
169
+ if not element.session_id and not element.dx and not element.dy:
170
+ return False
171
+ try:
172
+ described = await session.client.send(
173
+ "DOM.describeNode",
174
+ {"backendNodeId": node_backend, "depth": 5},
175
+ session_id=session.session_id,
176
+ )
177
+ except Exception: # noqa: BLE001 - unknown tree means not verifiable
178
+ return False
179
+ node = described.get("node") or {}
180
+ if (node.get("nodeName", "") or "").upper() not in ("IFRAME", "OBJECT", "EMBED"):
181
+ return False
182
+ return await _backend_contains(
183
+ session, node_backend, element.backend_id, session.session_id
184
+ )
185
+
186
+
187
+ async def _backend_contains(
188
+ session: AgentSession, ancestor_backend: int, node_backend: int,
189
+ target_session: str,
190
+ ) -> bool:
191
+ """Bounded subtree search for one backend id under another."""
192
+ for pierce in (True, False):
193
+ try:
194
+ described = await session.client.send(
195
+ "DOM.describeNode",
196
+ {"backendNodeId": ancestor_backend, "depth": 5,
197
+ "pierce": pierce},
198
+ session_id=target_session,
199
+ )
200
+ except Exception: # noqa: BLE001 - unknown tree means not verifiable
201
+ continue
202
+ if _subtree_has(described.get("node") or {}, node_backend):
203
+ return True
204
+ return False
205
+
206
+
207
+ def _subtree_has(node: dict, node_backend: int) -> bool:
208
+ """Iterative search through children, content documents, and shadow roots."""
209
+ stack = [node]
210
+ while stack:
211
+ current = stack.pop()
212
+ if current.get("backendNodeId") == node_backend:
213
+ return True
214
+ stack.extend(
215
+ child for child in current.get("children", []) or []
216
+ if isinstance(child, dict)
217
+ )
218
+ content = current.get("contentDocument")
219
+ if isinstance(content, dict):
220
+ stack.extend(
221
+ child for child in content.get("children", []) or []
222
+ if isinstance(child, dict)
223
+ )
224
+ for shadow in current.get("shadowRoots", []) or []:
225
+ if isinstance(shadow, dict):
226
+ stack.extend(
227
+ child for child in shadow.get("children", []) or []
228
+ if isinstance(child, dict)
229
+ )
230
+ return False
231
+
232
+
233
+ async def coverage_owner(
234
+ session: AgentSession, x: float, y: float
235
+ ) -> int | None:
236
+ """Backend id of the top node at viewport point (x, y), None when unknown.
237
+
238
+ getNodeForLocation expects DOCUMENT coordinates (verified: viewport
239
+ coords fail once scrolled), so the current scroll offset is added.
240
+ """
241
+ client = session.client
242
+ try:
243
+ metrics = await client.send(
244
+ "Page.getLayoutMetrics", session_id=session.session_id
245
+ )
246
+ page = metrics.get("layoutViewport", {}) or {}
247
+ doc_x = x + float(page.get("pageX", 0))
248
+ doc_y = y + float(page.get("pageY", 0))
249
+ result = await client.send(
250
+ "DOM.getNodeForLocation", {"x": int(doc_x), "y": int(doc_y)},
251
+ session_id=session.session_id,
252
+ )
253
+ backend_id = result.get("backendNodeId")
254
+ return backend_id if isinstance(backend_id, int) else None
255
+ except Exception: # noqa: BLE001 - coverage check is best-effort
256
+ return None
257
+
258
+
259
+ async def click_at(client: Any, session_id: str, x: float, y: float) -> None:
260
+ """Raw viewport click: move, press, release (left button)."""
261
+ await _mouse(client, session_id, "mouseMoved", x, y)
262
+ await _mouse(client, session_id, "mouseMoved", x, y)
263
+ await _mouse(
264
+ client, session_id, "mousePressed", x, y, button="left", clickCount=1
265
+ )
266
+ await _mouse(
267
+ client, session_id, "mouseReleased", x, y, button="left", clickCount=1
268
+ )
269
+
270
+
271
+ async def element_href(session: AgentSession, element: ElementRef) -> str:
272
+ """Raw href of one element (CDP read only, never JS). Empty when none.
273
+
274
+ Reads the node's own attributes via DOM.describeNode; ancestors are
275
+ not walked (documented limit: wrapped inner spans fall back to clicks).
276
+ """
277
+ try:
278
+ described = await session.client.send(
279
+ "DOM.describeNode",
280
+ {"backendNodeId": element.backend_id, "depth": 0},
281
+ session_id=element_session(session, element),
282
+ )
283
+ except Exception: # noqa: BLE001 - unknown node: no bypass
284
+ return ""
285
+ node = described.get("node", {}) if isinstance(described, dict) else {}
286
+ attrs = node.get("attributes", []) if isinstance(node, dict) else []
287
+ if not isinstance(attrs, list):
288
+ return ""
289
+ for index in range(0, len(attrs) - 1, 2):
290
+ if attrs[index] == "href" and isinstance(attrs[index + 1], str):
291
+ return attrs[index + 1]
292
+ return ""
293
+
294
+
295
+ async def key_press(
296
+ client: Any, session_id: str, key: str, modifiers: list[str] | None = None
297
+ ) -> None:
298
+ """Press and release one key with optional modifiers (real key events)."""
299
+ name, code, windows_code = parse_key(key)
300
+ mods = parse_modifiers(modifiers)
301
+ await _key(client, session_id, "keyDown", name, code, windows_code, mods)
302
+ await _key(client, session_id, "keyUp", name, code, windows_code, mods)
303
+
304
+
305
+ async def key_chars(
306
+ client: Any, session_id: str, text: str, delay: float = 0.0
307
+ ) -> None:
308
+ """Send text as char key events (for controls ignoring insertText).
309
+
310
+ With delay > 0 each character lands separately like manual typing
311
+ (human-like mode). Total time is bounded: 200 chars max, so even the
312
+ slowest supported delay keeps one call under ~15s.
313
+
314
+ Verified: native <select> type-ahead and date spinbuttons only react to
315
+ text (char) events, not to Input.insertText.
316
+ """
317
+ import asyncio
318
+
319
+ for char in text[:200]:
320
+ lowered = char.lower()
321
+ code = f"Key{char.upper()}" if char.isalpha() else (
322
+ f"Digit{char}" if char.isdigit() else ""
323
+ )
324
+ vk = ord(char.upper()) if char.isalnum() else 0
325
+ await _key(client, session_id, "rawKeyDown", lowered, code, vk)
326
+ await _key(client, session_id, "char", lowered, code, 0, text=char)
327
+ await _key(client, session_id, "keyUp", lowered, code, vk)
328
+ if delay > 0:
329
+ await asyncio.sleep(min(delay, 0.5))
330
+
331
+
332
+ HUMAN_CHAR_DELAY = 0.05 # seconds between keystrokes in human-like mode
333
+
334
+
335
+ async def type_text(
336
+ session: AgentSession,
337
+ element: ElementRef,
338
+ text: str,
339
+ clear: bool,
340
+ human: bool = False,
341
+ ) -> None:
342
+ """Focus the field, optionally clear it, insert text as real input.
343
+
344
+ Spinbuttons ignore insertText: they get char key events instead.
345
+ human=True types char-by-char with a small delay like manual typing
346
+ (slower, but indistinguishable from a keyboard for bot detection).
347
+ """
348
+ client = session.client
349
+ target = element_session(session, element)
350
+ try:
351
+ resolved = await client.send(
352
+ "DOM.resolveNode",
353
+ {"backendNodeId": element.backend_id},
354
+ session_id=target,
355
+ )
356
+ object_id = (resolved.get("object") or {}).get("objectId")
357
+ except Exception as exc: # noqa: BLE001 - resolution failure is a stale ref
358
+ raise ValueError(
359
+ f"error: ref_stale - {element.role} {element.name!r} is gone. "
360
+ "call page_snapshot()."
361
+ ) from exc
362
+ if not isinstance(object_id, str):
363
+ raise ValueError(
364
+ f"error: ref_stale - {element.role} {element.name!r} is gone. "
365
+ "call page_snapshot()."
366
+ )
367
+ await client.send("DOM.focus", {"objectId": object_id}, session_id=target)
368
+ if clear:
369
+ await key_press(client, target, "a", ["ctrl"])
370
+ await key_press(client, target, "Backspace")
371
+ if element.role == "spinbutton":
372
+ await key_chars(client, target, text)
373
+ elif human:
374
+ await key_chars(client, target, text, delay=HUMAN_CHAR_DELAY)
375
+ else:
376
+ await client.send(
377
+ "Input.insertText", {"text": text}, session_id=target
378
+ )
379
+
380
+
381
+ async def scroll_by(client: Any, session_id: str, dx: float, dy: float) -> None:
382
+ """Real wheel event at the viewport center."""
383
+ metrics = await client.send(
384
+ "Page.getLayoutMetrics", session_id=session_id
385
+ )
386
+ viewport = metrics.get("cssVisualViewport", {}) or {}
387
+ x = float(viewport.get("pageX", 0)) + float(viewport.get("width", 800)) / 2
388
+ y = float(viewport.get("pageY", 0)) + float(viewport.get("height", 600)) / 2
389
+ await _mouse(
390
+ client, session_id, "mouseWheel", x, y, deltaX=dx, deltaY=dy
391
+ )
392
+
393
+
394
+ async def scroll_to(session: AgentSession, element: ElementRef) -> None:
395
+ """Bring the element into view with a margin for sticky headers."""
396
+ target = element_session(session, element)
397
+ await session.client.send(
398
+ "DOM.scrollIntoViewIfNeeded",
399
+ {"backendNodeId": element.backend_id},
400
+ session_id=target,
401
+ )
402
+ await scroll_by(session.client, session.session_id, 0.0, -80.0)
403
+ await settle_scroll(session.client, session.session_id)
404
+ # Near the page bottom the -80 margin can push a bottom element back
405
+ # below the fold (verified): re-reveal without margin when still hidden.
406
+ try:
407
+ result = await session.client.send(
408
+ "DOM.getContentQuads", {"backendNodeId": element.backend_id},
409
+ session_id=target,
410
+ )
411
+ quads = result.get("quads", [])
412
+ center = quad_center(list(quads[0])) if quads else None
413
+ if center is None:
414
+ return
415
+ metrics = await session.client.send(
416
+ "Page.getLayoutMetrics", session_id=session.session_id
417
+ )
418
+ viewport = metrics.get("cssVisualViewport", {}) or {}
419
+ height = float(viewport.get("height", 0) or 0)
420
+ if not height:
421
+ page = metrics.get("layoutViewport", {}) or {}
422
+ height = float(page.get("clientHeight", 0) or 0)
423
+ if height and not (0 <= center[1] <= height):
424
+ await session.client.send(
425
+ "DOM.scrollIntoViewIfNeeded",
426
+ {"backendNodeId": element.backend_id},
427
+ session_id=target,
428
+ )
429
+ await settle_scroll(session.client, session.session_id)
430
+ except Exception: # noqa: BLE001 - re-reveal is best-effort
431
+ pass
432
+
433
+
434
+ async def hover(session: AgentSession, element: ElementRef) -> None:
435
+ """Gradual mouse approach so hover menus appear (spec 11.23)."""
436
+ x, y = await element_center(session, element)
437
+ target = element_session(session, element)
438
+ steps = 8
439
+ await session.client.send(
440
+ "Input.dispatchMouseEvent",
441
+ {"type": "mouseMoved", "x": x - 40.0, "y": y - 20.0},
442
+ session_id=target,
443
+ )
444
+ for step in range(1, steps + 1):
445
+ await _mouse(
446
+ session.client, target, "mouseMoved",
447
+ x - 40.0 + 40.0 * step / steps, y - 20.0 + 20.0 * step / steps,
448
+ )
449
+
450
+
451
+ async def select_option(
452
+ session: AgentSession, element: ElementRef, value: str
453
+ ) -> None:
454
+ """Native <select> via keyboard type-ahead (no JS). Caller verifies.
455
+
456
+ Type-ahead matching consumes text (char) events: rawKeyDown + char +
457
+ keyUp per character. Plain keyDown/Up alone does not drive matching,
458
+ and Enter would open the native popup instead.
459
+ """
460
+ if element.role != "combobox":
461
+ raise ValueError(
462
+ f"error: invalid_input - ref {element.ref} is {element.role}, "
463
+ "not a select box. For custom dropdowns: click, then click the option."
464
+ )
465
+ target = element_session(session, element)
466
+ await type_text(session, element, "", clear=False)
467
+ await key_chars(client=session.client, session_id=target, text=value[:30])
468
+ await key_press(session.client, target, "Tab")
469
+
470
+
471
+ def combobox_value(element: ElementRef) -> str:
472
+ """Current value state of a select box element."""
473
+ for state in element.states:
474
+ if state.startswith('value="') and state.endswith('"'):
475
+ return state[len('value="') : -1]
476
+ return ""
477
+
478
+
479
+ async def drag(
480
+ session: AgentSession, source: ElementRef, target_element: ElementRef
481
+ ) -> None:
482
+ """Press, graded moves, release. Both ends must share one session."""
483
+ if element_session(session, source) != element_session(session, target_element):
484
+ raise ValueError(
485
+ "error: invalid_input - drag ends live in different frames. "
486
+ "HTML5 cross-frame DnD is unsupported."
487
+ )
488
+ target = element_session(session, source)
489
+ x1, y1 = await element_center(session, source)
490
+ x2, y2 = await element_center(session, target_element)
491
+ client = session.client
492
+ await _mouse(client, target, "mouseMoved", x1, y1)
493
+ await _mouse(client, target, "mousePressed", x1, y1, button="left",
494
+ clickCount=1)
495
+ for step in range(1, 13):
496
+ await _mouse(
497
+ client, target, "mouseMoved",
498
+ x1 + (x2 - x1) * step / 12, y1 + (y2 - y1) * step / 12,
499
+ )
500
+ await _mouse(client, target, "mouseReleased", x2, y2, button="left",
501
+ clickCount=1)
502
+ # Synthetic mouse drags rarely complete the HTML5 drop handshake, which can
503
+ # leave hit-testing stuck on the last drag target (verified). Escape
504
+ # cancels any stuck drag operation.
505
+ await key_press(client, target, "Escape")
506
+
507
+
508
+ async def upload_file(
509
+ session: AgentSession,
510
+ element: ElementRef,
511
+ path: str,
512
+ workspace_dir: str,
513
+ ) -> None:
514
+ """Set files on an <input type=file>. Path must live in the workspace."""
515
+ from pathlib import Path
516
+
517
+ candidate = Path(path).resolve()
518
+ root = Path(workspace_dir).resolve()
519
+ if root not in candidate.parents and candidate != root:
520
+ raise ValueError(
521
+ f"error: path_forbidden - {path!r} is outside the workspace. "
522
+ "upload_file paths must live inside the workspace."
523
+ )
524
+ if not candidate.is_file():
525
+ raise ValueError(f"error: file_not_found - no file at {path!r}.")
526
+ if "file" not in element.role and "button" not in element.role:
527
+ raise ValueError(
528
+ f"error: invalid_input - ref {element.ref} is {element.role}, "
529
+ "not a file input."
530
+ )
531
+ await session.client.send(
532
+ "DOM.setFileInputFiles",
533
+ {"files": [str(candidate)], "backendNodeId": element.backend_id},
534
+ session_id=element_session(session, element),
535
+ )