strix-agent 0.1.1__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 (99) hide show
  1. strix/__init__.py +0 -0
  2. strix/agents/StrixAgent/__init__.py +4 -0
  3. strix/agents/StrixAgent/strix_agent.py +60 -0
  4. strix/agents/StrixAgent/system_prompt.jinja +504 -0
  5. strix/agents/__init__.py +10 -0
  6. strix/agents/base_agent.py +394 -0
  7. strix/agents/state.py +139 -0
  8. strix/cli/__init__.py +4 -0
  9. strix/cli/app.py +1124 -0
  10. strix/cli/assets/cli.tcss +680 -0
  11. strix/cli/main.py +542 -0
  12. strix/cli/tool_components/__init__.py +39 -0
  13. strix/cli/tool_components/agents_graph_renderer.py +129 -0
  14. strix/cli/tool_components/base_renderer.py +61 -0
  15. strix/cli/tool_components/browser_renderer.py +107 -0
  16. strix/cli/tool_components/file_edit_renderer.py +95 -0
  17. strix/cli/tool_components/finish_renderer.py +32 -0
  18. strix/cli/tool_components/notes_renderer.py +108 -0
  19. strix/cli/tool_components/proxy_renderer.py +255 -0
  20. strix/cli/tool_components/python_renderer.py +34 -0
  21. strix/cli/tool_components/registry.py +72 -0
  22. strix/cli/tool_components/reporting_renderer.py +53 -0
  23. strix/cli/tool_components/scan_info_renderer.py +58 -0
  24. strix/cli/tool_components/terminal_renderer.py +99 -0
  25. strix/cli/tool_components/thinking_renderer.py +29 -0
  26. strix/cli/tool_components/user_message_renderer.py +43 -0
  27. strix/cli/tool_components/web_search_renderer.py +28 -0
  28. strix/cli/tracer.py +308 -0
  29. strix/llm/__init__.py +14 -0
  30. strix/llm/config.py +19 -0
  31. strix/llm/llm.py +310 -0
  32. strix/llm/memory_compressor.py +206 -0
  33. strix/llm/request_queue.py +63 -0
  34. strix/llm/utils.py +84 -0
  35. strix/prompts/__init__.py +113 -0
  36. strix/prompts/coordination/root_agent.jinja +41 -0
  37. strix/prompts/vulnerabilities/authentication_jwt.jinja +129 -0
  38. strix/prompts/vulnerabilities/business_logic.jinja +143 -0
  39. strix/prompts/vulnerabilities/csrf.jinja +168 -0
  40. strix/prompts/vulnerabilities/idor.jinja +164 -0
  41. strix/prompts/vulnerabilities/race_conditions.jinja +194 -0
  42. strix/prompts/vulnerabilities/rce.jinja +222 -0
  43. strix/prompts/vulnerabilities/sql_injection.jinja +216 -0
  44. strix/prompts/vulnerabilities/ssrf.jinja +168 -0
  45. strix/prompts/vulnerabilities/xss.jinja +221 -0
  46. strix/prompts/vulnerabilities/xxe.jinja +276 -0
  47. strix/runtime/__init__.py +19 -0
  48. strix/runtime/docker_runtime.py +298 -0
  49. strix/runtime/runtime.py +25 -0
  50. strix/runtime/tool_server.py +97 -0
  51. strix/tools/__init__.py +64 -0
  52. strix/tools/agents_graph/__init__.py +16 -0
  53. strix/tools/agents_graph/agents_graph_actions.py +610 -0
  54. strix/tools/agents_graph/agents_graph_actions_schema.xml +223 -0
  55. strix/tools/argument_parser.py +120 -0
  56. strix/tools/browser/__init__.py +4 -0
  57. strix/tools/browser/browser_actions.py +236 -0
  58. strix/tools/browser/browser_actions_schema.xml +183 -0
  59. strix/tools/browser/browser_instance.py +533 -0
  60. strix/tools/browser/tab_manager.py +342 -0
  61. strix/tools/executor.py +302 -0
  62. strix/tools/file_edit/__init__.py +4 -0
  63. strix/tools/file_edit/file_edit_actions.py +141 -0
  64. strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
  65. strix/tools/finish/__init__.py +4 -0
  66. strix/tools/finish/finish_actions.py +167 -0
  67. strix/tools/finish/finish_actions_schema.xml +45 -0
  68. strix/tools/notes/__init__.py +14 -0
  69. strix/tools/notes/notes_actions.py +191 -0
  70. strix/tools/notes/notes_actions_schema.xml +150 -0
  71. strix/tools/proxy/__init__.py +20 -0
  72. strix/tools/proxy/proxy_actions.py +101 -0
  73. strix/tools/proxy/proxy_actions_schema.xml +267 -0
  74. strix/tools/proxy/proxy_manager.py +785 -0
  75. strix/tools/python/__init__.py +4 -0
  76. strix/tools/python/python_actions.py +47 -0
  77. strix/tools/python/python_actions_schema.xml +131 -0
  78. strix/tools/python/python_instance.py +172 -0
  79. strix/tools/python/python_manager.py +131 -0
  80. strix/tools/registry.py +196 -0
  81. strix/tools/reporting/__init__.py +6 -0
  82. strix/tools/reporting/reporting_actions.py +63 -0
  83. strix/tools/reporting/reporting_actions_schema.xml +30 -0
  84. strix/tools/terminal/__init__.py +4 -0
  85. strix/tools/terminal/terminal_actions.py +53 -0
  86. strix/tools/terminal/terminal_actions_schema.xml +114 -0
  87. strix/tools/terminal/terminal_instance.py +231 -0
  88. strix/tools/terminal/terminal_manager.py +191 -0
  89. strix/tools/thinking/__init__.py +4 -0
  90. strix/tools/thinking/thinking_actions.py +18 -0
  91. strix/tools/thinking/thinking_actions_schema.xml +52 -0
  92. strix/tools/web_search/__init__.py +4 -0
  93. strix/tools/web_search/web_search_actions.py +80 -0
  94. strix/tools/web_search/web_search_actions_schema.xml +83 -0
  95. strix_agent-0.1.1.dist-info/LICENSE +201 -0
  96. strix_agent-0.1.1.dist-info/METADATA +200 -0
  97. strix_agent-0.1.1.dist-info/RECORD +99 -0
  98. strix_agent-0.1.1.dist-info/WHEEL +4 -0
  99. strix_agent-0.1.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,533 @@
1
+ import asyncio
2
+ import base64
3
+ import logging
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ MAX_PAGE_SOURCE_LENGTH = 20_000
14
+ MAX_CONSOLE_LOG_LENGTH = 30_000
15
+ MAX_INDIVIDUAL_LOG_LENGTH = 1_000
16
+ MAX_CONSOLE_LOGS_COUNT = 200
17
+ MAX_JS_RESULT_LENGTH = 5_000
18
+
19
+
20
+ class BrowserInstance:
21
+ def __init__(self) -> None:
22
+ self.is_running = True
23
+ self._execution_lock = threading.Lock()
24
+
25
+ self.playwright: Playwright | None = None
26
+ self.browser: Browser | None = None
27
+ self.context: BrowserContext | None = None
28
+ self.pages: dict[str, Page] = {}
29
+ self.current_page_id: str | None = None
30
+ self._next_tab_id = 1
31
+
32
+ self.console_logs: dict[str, list[dict[str, Any]]] = {}
33
+
34
+ self._loop: asyncio.AbstractEventLoop | None = None
35
+ self._loop_thread: threading.Thread | None = None
36
+
37
+ self._start_event_loop()
38
+
39
+ def _start_event_loop(self) -> None:
40
+ def run_loop() -> None:
41
+ self._loop = asyncio.new_event_loop()
42
+ asyncio.set_event_loop(self._loop)
43
+ self._loop.run_forever()
44
+
45
+ self._loop_thread = threading.Thread(target=run_loop, daemon=True)
46
+ self._loop_thread.start()
47
+
48
+ while self._loop is None:
49
+ threading.Event().wait(0.01)
50
+
51
+ def _run_async(self, coro: Any) -> dict[str, Any]:
52
+ if not self._loop or not self.is_running:
53
+ raise RuntimeError("Browser instance is not running")
54
+
55
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
56
+ return cast("dict[str, Any]", future.result(timeout=30)) # 30 second timeout
57
+
58
+ async def _setup_console_logging(self, page: Page, tab_id: str) -> None:
59
+ self.console_logs[tab_id] = []
60
+
61
+ def handle_console(msg: Any) -> None:
62
+ text = msg.text
63
+ if len(text) > MAX_INDIVIDUAL_LOG_LENGTH:
64
+ text = text[:MAX_INDIVIDUAL_LOG_LENGTH] + "... [TRUNCATED]"
65
+
66
+ log_entry = {
67
+ "type": msg.type,
68
+ "text": text,
69
+ "location": msg.location,
70
+ "timestamp": asyncio.get_event_loop().time(),
71
+ }
72
+
73
+ self.console_logs[tab_id].append(log_entry)
74
+
75
+ if len(self.console_logs[tab_id]) > MAX_CONSOLE_LOGS_COUNT:
76
+ self.console_logs[tab_id] = self.console_logs[tab_id][-MAX_CONSOLE_LOGS_COUNT:]
77
+
78
+ page.on("console", handle_console)
79
+
80
+ async def _launch_browser(self, url: str | None = None) -> dict[str, Any]:
81
+ self.playwright = await async_playwright().start()
82
+
83
+ self.browser = await self.playwright.chromium.launch(
84
+ headless=True,
85
+ args=[
86
+ "--no-sandbox",
87
+ "--disable-dev-shm-usage",
88
+ "--disable-gpu",
89
+ "--disable-web-security",
90
+ "--disable-features=VizDisplayCompositor",
91
+ ],
92
+ )
93
+
94
+ self.context = await self.browser.new_context(
95
+ viewport={"width": 1280, "height": 720},
96
+ user_agent=(
97
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
98
+ "(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
99
+ ),
100
+ )
101
+
102
+ page = await self.context.new_page()
103
+ tab_id = f"tab_{self._next_tab_id}"
104
+ self._next_tab_id += 1
105
+ self.pages[tab_id] = page
106
+ self.current_page_id = tab_id
107
+
108
+ await self._setup_console_logging(page, tab_id)
109
+
110
+ if url:
111
+ await page.goto(url, wait_until="domcontentloaded")
112
+
113
+ return await self._get_page_state(tab_id)
114
+
115
+ async def _get_page_state(self, tab_id: str | None = None) -> dict[str, Any]:
116
+ if not tab_id:
117
+ tab_id = self.current_page_id
118
+
119
+ if not tab_id or tab_id not in self.pages:
120
+ raise ValueError(f"Tab '{tab_id}' not found")
121
+
122
+ page = self.pages[tab_id]
123
+
124
+ await asyncio.sleep(2)
125
+
126
+ screenshot_bytes = await page.screenshot(type="png", full_page=False)
127
+ screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
128
+
129
+ url = page.url
130
+ title = await page.title()
131
+ viewport = page.viewport_size
132
+
133
+ all_tabs = {}
134
+ for tid, tab_page in self.pages.items():
135
+ all_tabs[tid] = {
136
+ "url": tab_page.url,
137
+ "title": await tab_page.title() if not tab_page.is_closed() else "Closed",
138
+ }
139
+
140
+ return {
141
+ "screenshot": screenshot_b64,
142
+ "url": url,
143
+ "title": title,
144
+ "viewport": viewport,
145
+ "tab_id": tab_id,
146
+ "all_tabs": all_tabs,
147
+ }
148
+
149
+ def launch(self, url: str | None = None) -> dict[str, Any]:
150
+ with self._execution_lock:
151
+ if self.browser is not None:
152
+ raise ValueError("Browser is already launched")
153
+
154
+ return self._run_async(self._launch_browser(url))
155
+
156
+ def goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
157
+ with self._execution_lock:
158
+ return self._run_async(self._goto(url, tab_id))
159
+
160
+ async def _goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
161
+ if not tab_id:
162
+ tab_id = self.current_page_id
163
+
164
+ if not tab_id or tab_id not in self.pages:
165
+ raise ValueError(f"Tab '{tab_id}' not found")
166
+
167
+ page = self.pages[tab_id]
168
+ await page.goto(url, wait_until="domcontentloaded")
169
+
170
+ return await self._get_page_state(tab_id)
171
+
172
+ def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
173
+ with self._execution_lock:
174
+ return self._run_async(self._click(coordinate, tab_id))
175
+
176
+ async def _click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
177
+ if not tab_id:
178
+ tab_id = self.current_page_id
179
+
180
+ if not tab_id or tab_id not in self.pages:
181
+ raise ValueError(f"Tab '{tab_id}' not found")
182
+
183
+ try:
184
+ x, y = map(int, coordinate.split(","))
185
+ except ValueError as e:
186
+ raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
187
+
188
+ page = self.pages[tab_id]
189
+ await page.mouse.click(x, y)
190
+
191
+ return await self._get_page_state(tab_id)
192
+
193
+ def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
194
+ with self._execution_lock:
195
+ return self._run_async(self._type_text(text, tab_id))
196
+
197
+ async def _type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
198
+ if not tab_id:
199
+ tab_id = self.current_page_id
200
+
201
+ if not tab_id or tab_id not in self.pages:
202
+ raise ValueError(f"Tab '{tab_id}' not found")
203
+
204
+ page = self.pages[tab_id]
205
+ await page.keyboard.type(text)
206
+
207
+ return await self._get_page_state(tab_id)
208
+
209
+ def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
210
+ with self._execution_lock:
211
+ return self._run_async(self._scroll(direction, tab_id))
212
+
213
+ async def _scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
214
+ if not tab_id:
215
+ tab_id = self.current_page_id
216
+
217
+ if not tab_id or tab_id not in self.pages:
218
+ raise ValueError(f"Tab '{tab_id}' not found")
219
+
220
+ page = self.pages[tab_id]
221
+
222
+ if direction == "down":
223
+ await page.keyboard.press("PageDown")
224
+ elif direction == "up":
225
+ await page.keyboard.press("PageUp")
226
+ else:
227
+ raise ValueError(f"Invalid scroll direction: {direction}")
228
+
229
+ return await self._get_page_state(tab_id)
230
+
231
+ def back(self, tab_id: str | None = None) -> dict[str, Any]:
232
+ with self._execution_lock:
233
+ return self._run_async(self._back(tab_id))
234
+
235
+ async def _back(self, tab_id: str | None = None) -> dict[str, Any]:
236
+ if not tab_id:
237
+ tab_id = self.current_page_id
238
+
239
+ if not tab_id or tab_id not in self.pages:
240
+ raise ValueError(f"Tab '{tab_id}' not found")
241
+
242
+ page = self.pages[tab_id]
243
+ await page.go_back(wait_until="domcontentloaded")
244
+
245
+ return await self._get_page_state(tab_id)
246
+
247
+ def forward(self, tab_id: str | None = None) -> dict[str, Any]:
248
+ with self._execution_lock:
249
+ return self._run_async(self._forward(tab_id))
250
+
251
+ async def _forward(self, tab_id: str | None = None) -> dict[str, Any]:
252
+ if not tab_id:
253
+ tab_id = self.current_page_id
254
+
255
+ if not tab_id or tab_id not in self.pages:
256
+ raise ValueError(f"Tab '{tab_id}' not found")
257
+
258
+ page = self.pages[tab_id]
259
+ await page.go_forward(wait_until="domcontentloaded")
260
+
261
+ return await self._get_page_state(tab_id)
262
+
263
+ def new_tab(self, url: str | None = None) -> dict[str, Any]:
264
+ with self._execution_lock:
265
+ return self._run_async(self._new_tab(url))
266
+
267
+ async def _new_tab(self, url: str | None = None) -> dict[str, Any]:
268
+ if not self.context:
269
+ raise ValueError("Browser not launched")
270
+
271
+ page = await self.context.new_page()
272
+ tab_id = f"tab_{self._next_tab_id}"
273
+ self._next_tab_id += 1
274
+ self.pages[tab_id] = page
275
+ self.current_page_id = tab_id
276
+
277
+ await self._setup_console_logging(page, tab_id)
278
+
279
+ if url:
280
+ await page.goto(url, wait_until="domcontentloaded")
281
+
282
+ return await self._get_page_state(tab_id)
283
+
284
+ def switch_tab(self, tab_id: str) -> dict[str, Any]:
285
+ with self._execution_lock:
286
+ return self._run_async(self._switch_tab(tab_id))
287
+
288
+ async def _switch_tab(self, tab_id: str) -> dict[str, Any]:
289
+ if tab_id not in self.pages:
290
+ raise ValueError(f"Tab '{tab_id}' not found")
291
+
292
+ self.current_page_id = tab_id
293
+ return await self._get_page_state(tab_id)
294
+
295
+ def close_tab(self, tab_id: str) -> dict[str, Any]:
296
+ with self._execution_lock:
297
+ return self._run_async(self._close_tab(tab_id))
298
+
299
+ async def _close_tab(self, tab_id: str) -> dict[str, Any]:
300
+ if tab_id not in self.pages:
301
+ raise ValueError(f"Tab '{tab_id}' not found")
302
+
303
+ if len(self.pages) == 1:
304
+ raise ValueError("Cannot close the last tab")
305
+
306
+ page = self.pages.pop(tab_id)
307
+ await page.close()
308
+
309
+ if tab_id in self.console_logs:
310
+ del self.console_logs[tab_id]
311
+
312
+ if self.current_page_id == tab_id:
313
+ self.current_page_id = next(iter(self.pages.keys()))
314
+
315
+ return await self._get_page_state(self.current_page_id)
316
+
317
+ def wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
318
+ with self._execution_lock:
319
+ return self._run_async(self._wait(duration, tab_id))
320
+
321
+ async def _wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
322
+ await asyncio.sleep(duration)
323
+ return await self._get_page_state(tab_id)
324
+
325
+ def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
326
+ with self._execution_lock:
327
+ return self._run_async(self._execute_js(js_code, tab_id))
328
+
329
+ async def _execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
330
+ if not tab_id:
331
+ tab_id = self.current_page_id
332
+
333
+ if not tab_id or tab_id not in self.pages:
334
+ raise ValueError(f"Tab '{tab_id}' not found")
335
+
336
+ page = self.pages[tab_id]
337
+
338
+ try:
339
+ result = await page.evaluate(js_code)
340
+ except Exception as e: # noqa: BLE001
341
+ result = {
342
+ "error": True,
343
+ "error_type": type(e).__name__,
344
+ "error_message": str(e),
345
+ }
346
+
347
+ result_str = str(result)
348
+ if len(result_str) > MAX_JS_RESULT_LENGTH:
349
+ result = result_str[:MAX_JS_RESULT_LENGTH] + "... [JS result truncated at 5k chars]"
350
+
351
+ state = await self._get_page_state(tab_id)
352
+ state["js_result"] = result
353
+ return state
354
+
355
+ def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:
356
+ with self._execution_lock:
357
+ return self._run_async(self._get_console_logs(tab_id, clear))
358
+
359
+ async def _get_console_logs(
360
+ self, tab_id: str | None = None, clear: bool = False
361
+ ) -> dict[str, Any]:
362
+ if not tab_id:
363
+ tab_id = self.current_page_id
364
+
365
+ if not tab_id or tab_id not in self.pages:
366
+ raise ValueError(f"Tab '{tab_id}' not found")
367
+
368
+ logs = self.console_logs.get(tab_id, [])
369
+
370
+ total_length = sum(len(str(log)) for log in logs)
371
+ if total_length > MAX_CONSOLE_LOG_LENGTH:
372
+ truncated_logs: list[dict[str, Any]] = []
373
+ current_length = 0
374
+
375
+ for log in reversed(logs):
376
+ log_length = len(str(log))
377
+ if current_length + log_length <= MAX_CONSOLE_LOG_LENGTH:
378
+ truncated_logs.insert(0, log)
379
+ current_length += log_length
380
+ else:
381
+ break
382
+
383
+ if len(truncated_logs) < len(logs):
384
+ truncation_notice = {
385
+ "type": "info",
386
+ "text": (
387
+ f"[TRUNCATED: {len(logs) - len(truncated_logs)} older logs "
388
+ f"removed to stay within {MAX_CONSOLE_LOG_LENGTH} character limit]"
389
+ ),
390
+ "location": {},
391
+ "timestamp": 0,
392
+ }
393
+ truncated_logs.insert(0, truncation_notice)
394
+
395
+ logs = truncated_logs
396
+
397
+ if clear:
398
+ self.console_logs[tab_id] = []
399
+
400
+ state = await self._get_page_state(tab_id)
401
+ state["console_logs"] = logs
402
+ return state
403
+
404
+ def view_source(self, tab_id: str | None = None) -> dict[str, Any]:
405
+ with self._execution_lock:
406
+ return self._run_async(self._view_source(tab_id))
407
+
408
+ async def _view_source(self, tab_id: str | None = None) -> dict[str, Any]:
409
+ if not tab_id:
410
+ tab_id = self.current_page_id
411
+
412
+ if not tab_id or tab_id not in self.pages:
413
+ raise ValueError(f"Tab '{tab_id}' not found")
414
+
415
+ page = self.pages[tab_id]
416
+ source = await page.content()
417
+ original_length = len(source)
418
+
419
+ if original_length > MAX_PAGE_SOURCE_LENGTH:
420
+ truncation_message = (
421
+ f"\n\n<!-- [TRUNCATED: {original_length - MAX_PAGE_SOURCE_LENGTH} "
422
+ "characters removed] -->\n\n"
423
+ )
424
+ available_space = MAX_PAGE_SOURCE_LENGTH - len(truncation_message)
425
+ truncate_point = available_space // 2
426
+
427
+ source = source[:truncate_point] + truncation_message + source[-truncate_point:]
428
+
429
+ state = await self._get_page_state(tab_id)
430
+ state["page_source"] = source
431
+ return state
432
+
433
+ def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
434
+ with self._execution_lock:
435
+ return self._run_async(self._double_click(coordinate, tab_id))
436
+
437
+ async def _double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
438
+ if not tab_id:
439
+ tab_id = self.current_page_id
440
+
441
+ if not tab_id or tab_id not in self.pages:
442
+ raise ValueError(f"Tab '{tab_id}' not found")
443
+
444
+ try:
445
+ x, y = map(int, coordinate.split(","))
446
+ except ValueError as e:
447
+ raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
448
+
449
+ page = self.pages[tab_id]
450
+ await page.mouse.dblclick(x, y)
451
+
452
+ return await self._get_page_state(tab_id)
453
+
454
+ def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
455
+ with self._execution_lock:
456
+ return self._run_async(self._hover(coordinate, tab_id))
457
+
458
+ async def _hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
459
+ if not tab_id:
460
+ tab_id = self.current_page_id
461
+
462
+ if not tab_id or tab_id not in self.pages:
463
+ raise ValueError(f"Tab '{tab_id}' not found")
464
+
465
+ try:
466
+ x, y = map(int, coordinate.split(","))
467
+ except ValueError as e:
468
+ raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
469
+
470
+ page = self.pages[tab_id]
471
+ await page.mouse.move(x, y)
472
+
473
+ return await self._get_page_state(tab_id)
474
+
475
+ def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
476
+ with self._execution_lock:
477
+ return self._run_async(self._press_key(key, tab_id))
478
+
479
+ async def _press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
480
+ if not tab_id:
481
+ tab_id = self.current_page_id
482
+
483
+ if not tab_id or tab_id not in self.pages:
484
+ raise ValueError(f"Tab '{tab_id}' not found")
485
+
486
+ page = self.pages[tab_id]
487
+ await page.keyboard.press(key)
488
+
489
+ return await self._get_page_state(tab_id)
490
+
491
+ def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
492
+ with self._execution_lock:
493
+ return self._run_async(self._save_pdf(file_path, tab_id))
494
+
495
+ async def _save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
496
+ if not tab_id:
497
+ tab_id = self.current_page_id
498
+
499
+ if not tab_id or tab_id not in self.pages:
500
+ raise ValueError(f"Tab '{tab_id}' not found")
501
+
502
+ if not Path(file_path).is_absolute():
503
+ file_path = str(Path("/workspace") / file_path)
504
+
505
+ page = self.pages[tab_id]
506
+ await page.pdf(path=file_path)
507
+
508
+ state = await self._get_page_state(tab_id)
509
+ state["pdf_saved"] = file_path
510
+ return state
511
+
512
+ def close(self) -> None:
513
+ with self._execution_lock:
514
+ self.is_running = False
515
+ if self._loop:
516
+ asyncio.run_coroutine_threadsafe(self._close_browser(), self._loop)
517
+
518
+ self._loop.call_soon_threadsafe(self._loop.stop)
519
+
520
+ if self._loop_thread:
521
+ self._loop_thread.join(timeout=5)
522
+
523
+ async def _close_browser(self) -> None:
524
+ try:
525
+ if self.browser:
526
+ await self.browser.close()
527
+ if self.playwright:
528
+ await self.playwright.stop()
529
+ except (OSError, RuntimeError) as e:
530
+ logger.warning(f"Error closing browser: {e}")
531
+
532
+ def is_alive(self) -> bool:
533
+ return self.is_running and self.browser is not None and self.browser.is_connected()