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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- 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())
|