gdmcode 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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
@@ -0,0 +1,352 @@
1
+ """Browser automation tools for gdm — DOM interaction, screenshots, navigation.
2
+
3
+ These tools communicate with the running Chrome extension via the bridge server.
4
+ Before using any browser tool, start the bridge: `gdm browser start` and load
5
+ the gdm-chrome extension in Chrome.
6
+
7
+ Three tools are registered at import time with a null bridge client:
8
+ - browser_dom — fill, click, assert, read DOM
9
+ - browser_capture — take screenshots
10
+ - browser_nav — navigate, wait for elements, reload
11
+
12
+ The AgentLoop or /browser command wires up the BridgeClient once the bridge is running.
13
+ Call set_bridge_client() to inject a live client into all three tools.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import logging
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import Any, ClassVar
23
+
24
+ from src.tools import REGISTRY, ToolBase, ToolResult
25
+
26
+ __all__ = [
27
+ "BrowserDomTool",
28
+ "BrowserCaptureTool",
29
+ "BrowserNavTool",
30
+ "set_bridge_client",
31
+ ]
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Constants
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _DEFAULT_ACTION_TIMEOUT_SECS: float = 10.0
40
+ _SCREENSHOT_DIR_NAME: str = "screenshots"
41
+
42
+ _DOM_ACTIONS: frozenset[str] = frozenset({
43
+ "click",
44
+ "fill",
45
+ "clear",
46
+ "type_text",
47
+ "select_option",
48
+ "keyboard_input",
49
+ "check",
50
+ "uncheck",
51
+ "hover",
52
+ "focus",
53
+ "assert_text",
54
+ "assert_visible",
55
+ "assert_hidden",
56
+ "assert_value",
57
+ "assert_checked",
58
+ "get_text",
59
+ "get_value",
60
+ "get_attribute",
61
+ # "eval" removed — arbitrary JS execution is prohibited (security)
62
+ "scroll_to",
63
+ "wait_for",
64
+ })
65
+
66
+ _NAV_ACTIONS: frozenset[str] = frozenset({
67
+ "navigate",
68
+ "reload",
69
+ "back",
70
+ "forward",
71
+ "wait",
72
+ })
73
+
74
+ # Shared bridge client — set once by set_bridge_client()
75
+ _shared_bridge: Any = None
76
+
77
+
78
+ def set_bridge_client(client: Any) -> None:
79
+ """Inject a live BridgeClient into all browser tools.
80
+
81
+ Call this after `gdm browser start` connects the bridge.
82
+ """
83
+ global _shared_bridge
84
+ _shared_bridge = client
85
+ log.info("BridgeClient wired into browser tools")
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Browser DOM tool
90
+ # ---------------------------------------------------------------------------
91
+
92
+ class BrowserDomTool(ToolBase):
93
+ """Interact with DOM elements in the browser via the gdm bridge.
94
+
95
+ Supports fill, click, assert, read, eval, and keyboard actions.
96
+ Requires `gdm browser start` and the gdm-chrome extension connected.
97
+ """
98
+
99
+ name: ClassVar[str] = "browser_dom"
100
+ description: ClassVar[str] = (
101
+ "Interact with the browser DOM: fill inputs, click buttons, assert text, "
102
+ "and read element values. "
103
+ "Requires the gdm bridge to be running (`gdm browser start`) and the "
104
+ "gdm-chrome extension loaded in Chrome."
105
+ )
106
+ input_schema: ClassVar[dict[str, Any]] = {
107
+ "type": "object",
108
+ "required": ["action"],
109
+ "properties": {
110
+ "action": {
111
+ "type": "string",
112
+ "enum": sorted(_DOM_ACTIONS),
113
+ "description": "The DOM action to perform.",
114
+ },
115
+ "selector": {
116
+ "type": "string",
117
+ "description": "CSS selector, XPath, or text-based selector.",
118
+ },
119
+ "value": {
120
+ "type": "string",
121
+ "description": "Value for fill/select_option/keyboard_input.",
122
+ },
123
+ "expected": {
124
+ "type": "string",
125
+ "description": "Expected value for assert_* actions.",
126
+ },
127
+ "attribute": {
128
+ "type": "string",
129
+ "description": "Attribute name for get_attribute.",
130
+ },
131
+ "exact": {
132
+ "type": "boolean",
133
+ "description": "Exact match for assert_text (default: substring).",
134
+ },
135
+ "timeout": {
136
+ "type": "number",
137
+ "description": "Action timeout in seconds (default: 10).",
138
+ },
139
+ "tab_id": {
140
+ "type": "integer",
141
+ "description": "Target tab ID (optional — uses bound tab if omitted).",
142
+ },
143
+ },
144
+ "additionalProperties": False,
145
+ }
146
+
147
+ def execute(self, params: dict[str, Any]) -> ToolResult:
148
+ action = params.get("action", "")
149
+ if action not in _DOM_ACTIONS:
150
+ return ToolResult(output="", error=f"Unknown DOM action: {action!r}")
151
+
152
+ bridge = _shared_bridge
153
+ if bridge is None or not bridge.is_connected:
154
+ return ToolResult(
155
+ output="",
156
+ error=(
157
+ "Browser bridge not connected. "
158
+ "Run `gdm browser start` then load the gdm-chrome extension."
159
+ ),
160
+ )
161
+
162
+ timeout = float(params.get("timeout", _DEFAULT_ACTION_TIMEOUT_SECS))
163
+ action_params = {k: v for k, v in params.items() if k not in ("action", "timeout")}
164
+
165
+ try:
166
+ response = bridge.send_command(action=action, params=action_params, timeout=timeout)
167
+ except TimeoutError:
168
+ return ToolResult(output="", error=f"Browser action '{action}' timed out after {timeout}s")
169
+ except ConnectionError as exc:
170
+ return ToolResult(output="", error=f"Bridge connection lost: {exc}")
171
+
172
+ if response.get("ok"):
173
+ return ToolResult(
174
+ output=json.dumps(response, indent=2),
175
+ metadata={"action": action, "selector": params.get("selector", "")},
176
+ )
177
+ return ToolResult(
178
+ output="",
179
+ error=response.get("error", "Unknown browser error"),
180
+ metadata={"action": action, "response": response},
181
+ )
182
+
183
+
184
+ # ---------------------------------------------------------------------------
185
+ # Browser capture tool
186
+ # ---------------------------------------------------------------------------
187
+
188
+ class BrowserCaptureTool(ToolBase):
189
+ """Take a screenshot of the current browser tab.
190
+
191
+ Screenshots are saved to .context-memory/screenshots/ and the file path
192
+ is returned. The agent can reference this path for vision analysis.
193
+ """
194
+
195
+ name: ClassVar[str] = "browser_capture"
196
+ description: ClassVar[str] = (
197
+ "Take a screenshot of the current browser tab. "
198
+ "Returns the file path to the saved PNG. "
199
+ "Use this to visually inspect UI state, verify layouts, or detect issues."
200
+ )
201
+ input_schema: ClassVar[dict[str, Any]] = {
202
+ "type": "object",
203
+ "properties": {
204
+ "tab_id": {
205
+ "type": "integer",
206
+ "description": "Target tab ID (optional — uses bound tab if omitted).",
207
+ },
208
+ "full_page": {
209
+ "type": "boolean",
210
+ "description": "Capture full scrollable page (default: visible viewport only).",
211
+ },
212
+ },
213
+ "additionalProperties": False,
214
+ }
215
+
216
+ def __init__(self, screenshot_dir: Path | None = None) -> None:
217
+ self._screenshot_dir = screenshot_dir or (
218
+ Path(".context-memory") / _SCREENSHOT_DIR_NAME
219
+ )
220
+
221
+ def execute(self, params: dict[str, Any]) -> ToolResult:
222
+ bridge = _shared_bridge
223
+ if bridge is None or not bridge.is_connected:
224
+ return ToolResult(
225
+ output="",
226
+ error="Browser bridge not connected. Run `gdm browser start`.",
227
+ )
228
+
229
+ try:
230
+ response = bridge.send_command(
231
+ action="screenshot",
232
+ params={
233
+ "tab_id": params.get("tab_id"),
234
+ "full_page": params.get("full_page", False),
235
+ },
236
+ timeout=15.0,
237
+ )
238
+ except TimeoutError:
239
+ return ToolResult(output="", error="Screenshot timed out")
240
+ except ConnectionError as exc:
241
+ return ToolResult(output="", error=f"Bridge connection lost: {exc}")
242
+
243
+ if not response.get("ok"):
244
+ return ToolResult(output="", error=response.get("error", "Screenshot failed"))
245
+
246
+ screenshot_b64: str = response.get("screenshot", "")
247
+ if not screenshot_b64:
248
+ return ToolResult(output="", error="No screenshot data received")
249
+
250
+ # Strip data URL prefix if present
251
+ if screenshot_b64.startswith("data:"):
252
+ screenshot_b64 = screenshot_b64.split(",", 1)[1]
253
+
254
+ self._screenshot_dir.mkdir(parents=True, exist_ok=True)
255
+ filename = f"screenshot_{uuid.uuid4().hex[:8]}.png"
256
+ filepath = self._screenshot_dir / filename
257
+ filepath.write_bytes(base64.b64decode(screenshot_b64))
258
+
259
+ return ToolResult(
260
+ output=f"Screenshot saved: {filepath}\nSize: {filepath.stat().st_size:,} bytes",
261
+ metadata={"path": str(filepath), "tab_id": params.get("tab_id")},
262
+ )
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # Browser nav tool
267
+ # ---------------------------------------------------------------------------
268
+
269
+ class BrowserNavTool(ToolBase):
270
+ """Navigate the browser to a URL, wait for conditions, or reload."""
271
+
272
+ name: ClassVar[str] = "browser_nav"
273
+ description: ClassVar[str] = (
274
+ "Navigate the browser to a URL, wait for an element to appear/disappear, "
275
+ "or reload the page. Use before DOM actions to ensure the right page is loaded."
276
+ )
277
+ input_schema: ClassVar[dict[str, Any]] = {
278
+ "type": "object",
279
+ "required": ["action"],
280
+ "properties": {
281
+ "action": {
282
+ "type": "string",
283
+ "enum": sorted(_NAV_ACTIONS),
284
+ "description": "Navigation action: navigate, wait, reload, back, forward.",
285
+ },
286
+ "url": {
287
+ "type": "string",
288
+ "description": "URL to navigate to (required for 'navigate').",
289
+ },
290
+ "selector": {
291
+ "type": "string",
292
+ "description": "CSS selector to wait for (required for 'wait').",
293
+ },
294
+ "gone": {
295
+ "type": "boolean",
296
+ "description": "If true, wait for element to disappear (default: false).",
297
+ },
298
+ "timeout": {
299
+ "type": "number",
300
+ "description": "Wait timeout in seconds (default: 10).",
301
+ },
302
+ "tab_id": {
303
+ "type": "integer",
304
+ "description": "Target tab ID (optional).",
305
+ },
306
+ },
307
+ "additionalProperties": False,
308
+ }
309
+
310
+ def execute(self, params: dict[str, Any]) -> ToolResult:
311
+ action = params.get("action", "")
312
+ if action not in _NAV_ACTIONS:
313
+ return ToolResult(output="", error=f"Unknown nav action: {action!r}")
314
+
315
+ if action == "navigate" and not params.get("url"):
316
+ return ToolResult(output="", error="'url' is required for navigate action")
317
+
318
+ if action == "wait" and not params.get("selector"):
319
+ return ToolResult(output="", error="'selector' is required for wait action")
320
+
321
+ bridge = _shared_bridge
322
+ if bridge is None or not bridge.is_connected:
323
+ return ToolResult(output="", error="Browser bridge not connected.")
324
+
325
+ timeout = float(params.get("timeout", _DEFAULT_ACTION_TIMEOUT_SECS))
326
+ if action == "navigate":
327
+ timeout = max(timeout, 30.0)
328
+
329
+ action_params = {k: v for k, v in params.items() if k not in ("action", "timeout")}
330
+
331
+ try:
332
+ response = bridge.send_command(action=action, params=action_params, timeout=timeout)
333
+ except TimeoutError:
334
+ return ToolResult(output="", error=f"'{action}' timed out after {timeout}s")
335
+ except ConnectionError as exc:
336
+ return ToolResult(output="", error=f"Bridge connection lost: {exc}")
337
+
338
+ if response.get("ok"):
339
+ return ToolResult(
340
+ output=json.dumps(response, indent=2),
341
+ metadata={"action": action},
342
+ )
343
+ return ToolResult(output="", error=response.get("error", "Navigation failed"))
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # Register tools
348
+ # ---------------------------------------------------------------------------
349
+
350
+ REGISTRY.register(BrowserDomTool())
351
+ REGISTRY.register(BrowserCaptureTool())
352
+ REGISTRY.register(BrowserNavTool())
@@ -0,0 +1,179 @@
1
+ """Async browser tools client for gdm — communicates with Chrome extension via bridge server.
2
+
3
+ BrowserTools (async) and BrowserToolsSync (sync wrapper) control a Chrome browser via
4
+ the gdm Chrome extension bridge at ws://localhost:7681.
5
+
6
+ websockets is an optional dependency; a clear ImportError is raised if missing at call time.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ try:
16
+ import websockets as websockets # noqa: PLC0414 (re-export for monkeypatching in tests)
17
+ _WEBSOCKETS_AVAILABLE = True
18
+ except ImportError:
19
+ websockets = None # type: ignore[assignment]
20
+ _WEBSOCKETS_AVAILABLE = False
21
+
22
+ __all__ = ["BrowserTools", "BrowserToolResult", "BrowserToolsSync"]
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # JS safety: patterns blocked in execute_js
26
+ # ---------------------------------------------------------------------------
27
+
28
+ _DANGEROUS_JS_PATTERNS: tuple[str, ...] = (
29
+ "fetch(",
30
+ "XMLHttpRequest",
31
+ "import(",
32
+ "eval(",
33
+ "Function(",
34
+ "indexedDB",
35
+ "localStorage",
36
+ "sessionStorage",
37
+ "document.cookie",
38
+ "chrome.runtime",
39
+ )
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # BrowserToolResult
44
+ # ---------------------------------------------------------------------------
45
+
46
+ @dataclass
47
+ class BrowserToolResult:
48
+ """Result of a browser tool operation."""
49
+
50
+ success: bool
51
+ data: Any
52
+ error: str | None = None
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # BrowserTools (async)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ class BrowserTools:
60
+ """Tools for controlling a browser via the gdm Chrome extension.
61
+
62
+ Each method opens a short-lived WebSocket connection to the bridge, sends
63
+ one JSON command, and awaits the response. Requires ``gdm browser start``
64
+ and the gdm-chrome extension loaded in Chrome.
65
+ """
66
+
67
+ def __init__(self, bridge_url: str = "ws://localhost:7681") -> None:
68
+ self._bridge_url = bridge_url
69
+
70
+ # ── Public async API ──────────────────────────────────────────────────
71
+
72
+ async def get_page_content(self) -> BrowserToolResult:
73
+ """Get the visible text content of the active tab."""
74
+ return await self._async_send({"type": "get_page_content"})
75
+
76
+ async def click_element(self, selector: str) -> BrowserToolResult:
77
+ """Click a DOM element by CSS selector."""
78
+ return await self._async_send({"type": "click_element", "selector": selector})
79
+
80
+ async def fill_input(self, selector: str, value: str) -> BrowserToolResult:
81
+ """Fill an input field by CSS selector."""
82
+ return await self._async_send(
83
+ {"type": "fill_input", "selector": selector, "value": value}
84
+ )
85
+
86
+ async def get_selected_text(self) -> BrowserToolResult:
87
+ """Get currently selected text in the browser."""
88
+ return await self._async_send({"type": "get_selected_text"})
89
+
90
+ async def execute_js(self, code: str) -> BrowserToolResult:
91
+ """Execute JavaScript in the active tab (sandboxed).
92
+
93
+ Rejects code that contains dangerous patterns such as ``fetch``,
94
+ ``XMLHttpRequest``, ``eval``, dynamic ``import``, and Web Storage /
95
+ cookie / chrome-API access.
96
+ """
97
+ for pattern in _DANGEROUS_JS_PATTERNS:
98
+ if pattern in code:
99
+ return BrowserToolResult(
100
+ success=False,
101
+ data=None,
102
+ error=f"Unsafe JS pattern rejected: '{pattern}'",
103
+ )
104
+ return await self._async_send({"type": "execute_js", "code": code})
105
+
106
+ async def screenshot(self) -> BrowserToolResult:
107
+ """Take a screenshot of the active tab. Returns base64 PNG in data field."""
108
+ return await self._async_send({"type": "screenshot"})
109
+
110
+ # ── Internal ──────────────────────────────────────────────────────────
111
+
112
+ def _send(self, msg: dict) -> BrowserToolResult:
113
+ """Sync wrapper around async send — useful for one-off calls outside an event loop."""
114
+ return asyncio.run(self._async_send(msg))
115
+
116
+ async def _async_send(self, msg: dict) -> BrowserToolResult:
117
+ """Open a WS connection, send *msg* as JSON, and return the parsed response."""
118
+ if not _WEBSOCKETS_AVAILABLE:
119
+ raise ImportError(
120
+ "The 'websockets' package is required for browser tools. "
121
+ "Install it with: pip install websockets"
122
+ )
123
+ try:
124
+ async with websockets.connect(self._bridge_url) as ws:
125
+ await ws.send(json.dumps(msg))
126
+ raw = await ws.recv()
127
+ data = json.loads(raw)
128
+ if data.get("ok"):
129
+ return BrowserToolResult(success=True, data=data.get("data"))
130
+ return BrowserToolResult(
131
+ success=False,
132
+ data=None,
133
+ error=data.get("error", "Unknown error from bridge"),
134
+ )
135
+ except (OSError, ConnectionRefusedError) as exc:
136
+ return BrowserToolResult(
137
+ success=False,
138
+ data=None,
139
+ error=f"Bridge connection failed: {exc}",
140
+ )
141
+ except Exception as exc: # noqa: BLE001
142
+ return BrowserToolResult(
143
+ success=False,
144
+ data=None,
145
+ error=f"Browser tool error: {exc}",
146
+ )
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # BrowserToolsSync (sync wrapper)
151
+ # ---------------------------------------------------------------------------
152
+
153
+ class BrowserToolsSync:
154
+ """Synchronous wrapper around :class:`BrowserTools` for non-async callers.
155
+
156
+ Each method calls ``asyncio.run()`` so it must be used outside any running
157
+ event loop (e.g. in scripts, CLI commands, or synchronous test helpers).
158
+ """
159
+
160
+ def __init__(self, bridge_url: str = "ws://localhost:7681") -> None:
161
+ self._async = BrowserTools(bridge_url)
162
+
163
+ def get_page_content(self) -> BrowserToolResult:
164
+ return asyncio.run(self._async.get_page_content())
165
+
166
+ def click_element(self, selector: str) -> BrowserToolResult:
167
+ return asyncio.run(self._async.click_element(selector))
168
+
169
+ def fill_input(self, selector: str, value: str) -> BrowserToolResult:
170
+ return asyncio.run(self._async.fill_input(selector, value))
171
+
172
+ def get_selected_text(self) -> BrowserToolResult:
173
+ return asyncio.run(self._async.get_selected_text())
174
+
175
+ def execute_js(self, code: str) -> BrowserToolResult:
176
+ return asyncio.run(self._async.execute_js(code))
177
+
178
+ def screenshot(self) -> BrowserToolResult:
179
+ return asyncio.run(self._async.screenshot())