aiptx 2.0.7__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 (187) hide show
  1. aipt_v2/__init__.py +110 -0
  2. aipt_v2/__main__.py +24 -0
  3. aipt_v2/agents/AIPTxAgent/__init__.py +10 -0
  4. aipt_v2/agents/AIPTxAgent/aiptx_agent.py +211 -0
  5. aipt_v2/agents/__init__.py +46 -0
  6. aipt_v2/agents/base.py +520 -0
  7. aipt_v2/agents/exploit_agent.py +688 -0
  8. aipt_v2/agents/ptt.py +406 -0
  9. aipt_v2/agents/state.py +168 -0
  10. aipt_v2/app.py +957 -0
  11. aipt_v2/browser/__init__.py +31 -0
  12. aipt_v2/browser/automation.py +458 -0
  13. aipt_v2/browser/crawler.py +453 -0
  14. aipt_v2/cli.py +2933 -0
  15. aipt_v2/compliance/__init__.py +71 -0
  16. aipt_v2/compliance/compliance_report.py +449 -0
  17. aipt_v2/compliance/framework_mapper.py +424 -0
  18. aipt_v2/compliance/nist_mapping.py +345 -0
  19. aipt_v2/compliance/owasp_mapping.py +330 -0
  20. aipt_v2/compliance/pci_mapping.py +297 -0
  21. aipt_v2/config.py +341 -0
  22. aipt_v2/core/__init__.py +43 -0
  23. aipt_v2/core/agent.py +630 -0
  24. aipt_v2/core/llm.py +395 -0
  25. aipt_v2/core/memory.py +305 -0
  26. aipt_v2/core/ptt.py +329 -0
  27. aipt_v2/database/__init__.py +14 -0
  28. aipt_v2/database/models.py +232 -0
  29. aipt_v2/database/repository.py +384 -0
  30. aipt_v2/docker/__init__.py +23 -0
  31. aipt_v2/docker/builder.py +260 -0
  32. aipt_v2/docker/manager.py +222 -0
  33. aipt_v2/docker/sandbox.py +371 -0
  34. aipt_v2/evasion/__init__.py +58 -0
  35. aipt_v2/evasion/request_obfuscator.py +272 -0
  36. aipt_v2/evasion/tls_fingerprint.py +285 -0
  37. aipt_v2/evasion/ua_rotator.py +301 -0
  38. aipt_v2/evasion/waf_bypass.py +439 -0
  39. aipt_v2/execution/__init__.py +23 -0
  40. aipt_v2/execution/executor.py +302 -0
  41. aipt_v2/execution/parser.py +544 -0
  42. aipt_v2/execution/terminal.py +337 -0
  43. aipt_v2/health.py +437 -0
  44. aipt_v2/intelligence/__init__.py +194 -0
  45. aipt_v2/intelligence/adaptation.py +474 -0
  46. aipt_v2/intelligence/auth.py +520 -0
  47. aipt_v2/intelligence/chaining.py +775 -0
  48. aipt_v2/intelligence/correlation.py +536 -0
  49. aipt_v2/intelligence/cve_aipt.py +334 -0
  50. aipt_v2/intelligence/cve_info.py +1111 -0
  51. aipt_v2/intelligence/knowledge_graph.py +590 -0
  52. aipt_v2/intelligence/learning.py +626 -0
  53. aipt_v2/intelligence/llm_analyzer.py +502 -0
  54. aipt_v2/intelligence/llm_tool_selector.py +518 -0
  55. aipt_v2/intelligence/payload_generator.py +562 -0
  56. aipt_v2/intelligence/rag.py +239 -0
  57. aipt_v2/intelligence/scope.py +442 -0
  58. aipt_v2/intelligence/searchers/__init__.py +5 -0
  59. aipt_v2/intelligence/searchers/exploitdb_searcher.py +523 -0
  60. aipt_v2/intelligence/searchers/github_searcher.py +467 -0
  61. aipt_v2/intelligence/searchers/google_searcher.py +281 -0
  62. aipt_v2/intelligence/tools.json +443 -0
  63. aipt_v2/intelligence/triage.py +670 -0
  64. aipt_v2/interactive_shell.py +559 -0
  65. aipt_v2/interface/__init__.py +5 -0
  66. aipt_v2/interface/cli.py +230 -0
  67. aipt_v2/interface/main.py +501 -0
  68. aipt_v2/interface/tui.py +1276 -0
  69. aipt_v2/interface/utils.py +583 -0
  70. aipt_v2/llm/__init__.py +39 -0
  71. aipt_v2/llm/config.py +26 -0
  72. aipt_v2/llm/llm.py +514 -0
  73. aipt_v2/llm/memory.py +214 -0
  74. aipt_v2/llm/request_queue.py +89 -0
  75. aipt_v2/llm/utils.py +89 -0
  76. aipt_v2/local_tool_installer.py +1467 -0
  77. aipt_v2/models/__init__.py +15 -0
  78. aipt_v2/models/findings.py +295 -0
  79. aipt_v2/models/phase_result.py +224 -0
  80. aipt_v2/models/scan_config.py +207 -0
  81. aipt_v2/monitoring/grafana/dashboards/aipt-dashboard.json +355 -0
  82. aipt_v2/monitoring/grafana/dashboards/default.yml +17 -0
  83. aipt_v2/monitoring/grafana/datasources/prometheus.yml +17 -0
  84. aipt_v2/monitoring/prometheus.yml +60 -0
  85. aipt_v2/orchestration/__init__.py +52 -0
  86. aipt_v2/orchestration/pipeline.py +398 -0
  87. aipt_v2/orchestration/progress.py +300 -0
  88. aipt_v2/orchestration/scheduler.py +296 -0
  89. aipt_v2/orchestrator.py +2427 -0
  90. aipt_v2/payloads/__init__.py +27 -0
  91. aipt_v2/payloads/cmdi.py +150 -0
  92. aipt_v2/payloads/sqli.py +263 -0
  93. aipt_v2/payloads/ssrf.py +204 -0
  94. aipt_v2/payloads/templates.py +222 -0
  95. aipt_v2/payloads/traversal.py +166 -0
  96. aipt_v2/payloads/xss.py +204 -0
  97. aipt_v2/prompts/__init__.py +60 -0
  98. aipt_v2/proxy/__init__.py +29 -0
  99. aipt_v2/proxy/history.py +352 -0
  100. aipt_v2/proxy/interceptor.py +452 -0
  101. aipt_v2/recon/__init__.py +44 -0
  102. aipt_v2/recon/dns.py +241 -0
  103. aipt_v2/recon/osint.py +367 -0
  104. aipt_v2/recon/subdomain.py +372 -0
  105. aipt_v2/recon/tech_detect.py +311 -0
  106. aipt_v2/reports/__init__.py +17 -0
  107. aipt_v2/reports/generator.py +313 -0
  108. aipt_v2/reports/html_report.py +378 -0
  109. aipt_v2/runtime/__init__.py +53 -0
  110. aipt_v2/runtime/base.py +30 -0
  111. aipt_v2/runtime/docker.py +401 -0
  112. aipt_v2/runtime/local.py +346 -0
  113. aipt_v2/runtime/tool_server.py +205 -0
  114. aipt_v2/runtime/vps.py +830 -0
  115. aipt_v2/scanners/__init__.py +28 -0
  116. aipt_v2/scanners/base.py +273 -0
  117. aipt_v2/scanners/nikto.py +244 -0
  118. aipt_v2/scanners/nmap.py +402 -0
  119. aipt_v2/scanners/nuclei.py +273 -0
  120. aipt_v2/scanners/web.py +454 -0
  121. aipt_v2/scripts/security_audit.py +366 -0
  122. aipt_v2/setup_wizard.py +941 -0
  123. aipt_v2/skills/__init__.py +80 -0
  124. aipt_v2/skills/agents/__init__.py +14 -0
  125. aipt_v2/skills/agents/api_tester.py +706 -0
  126. aipt_v2/skills/agents/base.py +477 -0
  127. aipt_v2/skills/agents/code_review.py +459 -0
  128. aipt_v2/skills/agents/security_agent.py +336 -0
  129. aipt_v2/skills/agents/web_pentest.py +818 -0
  130. aipt_v2/skills/prompts/__init__.py +647 -0
  131. aipt_v2/system_detector.py +539 -0
  132. aipt_v2/telemetry/__init__.py +7 -0
  133. aipt_v2/telemetry/tracer.py +347 -0
  134. aipt_v2/terminal/__init__.py +28 -0
  135. aipt_v2/terminal/executor.py +400 -0
  136. aipt_v2/terminal/sandbox.py +350 -0
  137. aipt_v2/tools/__init__.py +44 -0
  138. aipt_v2/tools/active_directory/__init__.py +78 -0
  139. aipt_v2/tools/active_directory/ad_config.py +238 -0
  140. aipt_v2/tools/active_directory/bloodhound_wrapper.py +447 -0
  141. aipt_v2/tools/active_directory/kerberos_attacks.py +430 -0
  142. aipt_v2/tools/active_directory/ldap_enum.py +533 -0
  143. aipt_v2/tools/active_directory/smb_attacks.py +505 -0
  144. aipt_v2/tools/agents_graph/__init__.py +19 -0
  145. aipt_v2/tools/agents_graph/agents_graph_actions.py +69 -0
  146. aipt_v2/tools/api_security/__init__.py +76 -0
  147. aipt_v2/tools/api_security/api_discovery.py +608 -0
  148. aipt_v2/tools/api_security/graphql_scanner.py +622 -0
  149. aipt_v2/tools/api_security/jwt_analyzer.py +577 -0
  150. aipt_v2/tools/api_security/openapi_fuzzer.py +761 -0
  151. aipt_v2/tools/browser/__init__.py +5 -0
  152. aipt_v2/tools/browser/browser_actions.py +238 -0
  153. aipt_v2/tools/browser/browser_instance.py +535 -0
  154. aipt_v2/tools/browser/tab_manager.py +344 -0
  155. aipt_v2/tools/cloud/__init__.py +70 -0
  156. aipt_v2/tools/cloud/cloud_config.py +273 -0
  157. aipt_v2/tools/cloud/cloud_scanner.py +639 -0
  158. aipt_v2/tools/cloud/prowler_tool.py +571 -0
  159. aipt_v2/tools/cloud/scoutsuite_tool.py +359 -0
  160. aipt_v2/tools/executor.py +307 -0
  161. aipt_v2/tools/parser.py +408 -0
  162. aipt_v2/tools/proxy/__init__.py +5 -0
  163. aipt_v2/tools/proxy/proxy_actions.py +103 -0
  164. aipt_v2/tools/proxy/proxy_manager.py +789 -0
  165. aipt_v2/tools/registry.py +196 -0
  166. aipt_v2/tools/scanners/__init__.py +343 -0
  167. aipt_v2/tools/scanners/acunetix_tool.py +712 -0
  168. aipt_v2/tools/scanners/burp_tool.py +631 -0
  169. aipt_v2/tools/scanners/config.py +156 -0
  170. aipt_v2/tools/scanners/nessus_tool.py +588 -0
  171. aipt_v2/tools/scanners/zap_tool.py +612 -0
  172. aipt_v2/tools/terminal/__init__.py +5 -0
  173. aipt_v2/tools/terminal/terminal_actions.py +37 -0
  174. aipt_v2/tools/terminal/terminal_manager.py +153 -0
  175. aipt_v2/tools/terminal/terminal_session.py +449 -0
  176. aipt_v2/tools/tool_processing.py +108 -0
  177. aipt_v2/utils/__init__.py +17 -0
  178. aipt_v2/utils/logging.py +202 -0
  179. aipt_v2/utils/model_manager.py +187 -0
  180. aipt_v2/utils/searchers/__init__.py +269 -0
  181. aipt_v2/verify_install.py +793 -0
  182. aiptx-2.0.7.dist-info/METADATA +345 -0
  183. aiptx-2.0.7.dist-info/RECORD +187 -0
  184. aiptx-2.0.7.dist-info/WHEEL +5 -0
  185. aiptx-2.0.7.dist-info/entry_points.txt +7 -0
  186. aiptx-2.0.7.dist-info/licenses/LICENSE +21 -0
  187. aiptx-2.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,535 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import logging
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ MAX_PAGE_SOURCE_LENGTH = 20_000
16
+ MAX_CONSOLE_LOG_LENGTH = 30_000
17
+ MAX_INDIVIDUAL_LOG_LENGTH = 1_000
18
+ MAX_CONSOLE_LOGS_COUNT = 200
19
+ MAX_JS_RESULT_LENGTH = 5_000
20
+
21
+
22
+ class BrowserInstance:
23
+ def __init__(self) -> None:
24
+ self.is_running = True
25
+ self._execution_lock = threading.Lock()
26
+
27
+ self.playwright: Playwright | None = None
28
+ self.browser: Browser | None = None
29
+ self.context: BrowserContext | None = None
30
+ self.pages: dict[str, Page] = {}
31
+ self.current_page_id: str | None = None
32
+ self._next_tab_id = 1
33
+
34
+ self.console_logs: dict[str, list[dict[str, Any]]] = {}
35
+
36
+ self._loop: asyncio.AbstractEventLoop | None = None
37
+ self._loop_thread: threading.Thread | None = None
38
+
39
+ self._start_event_loop()
40
+
41
+ def _start_event_loop(self) -> None:
42
+ def run_loop() -> None:
43
+ self._loop = asyncio.new_event_loop()
44
+ asyncio.set_event_loop(self._loop)
45
+ self._loop.run_forever()
46
+
47
+ self._loop_thread = threading.Thread(target=run_loop, daemon=True)
48
+ self._loop_thread.start()
49
+
50
+ while self._loop is None:
51
+ threading.Event().wait(0.01)
52
+
53
+ def _run_async(self, coro: Any) -> dict[str, Any]:
54
+ if not self._loop or not self.is_running:
55
+ raise RuntimeError("Browser instance is not running")
56
+
57
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
58
+ return cast("dict[str, Any]", future.result(timeout=30)) # 30 second timeout
59
+
60
+ async def _setup_console_logging(self, page: Page, tab_id: str) -> None:
61
+ self.console_logs[tab_id] = []
62
+
63
+ def handle_console(msg: Any) -> None:
64
+ text = msg.text
65
+ if len(text) > MAX_INDIVIDUAL_LOG_LENGTH:
66
+ text = text[:MAX_INDIVIDUAL_LOG_LENGTH] + "... [TRUNCATED]"
67
+
68
+ log_entry = {
69
+ "type": msg.type,
70
+ "text": text,
71
+ "location": msg.location,
72
+ "timestamp": asyncio.get_event_loop().time(),
73
+ }
74
+
75
+ self.console_logs[tab_id].append(log_entry)
76
+
77
+ if len(self.console_logs[tab_id]) > MAX_CONSOLE_LOGS_COUNT:
78
+ self.console_logs[tab_id] = self.console_logs[tab_id][-MAX_CONSOLE_LOGS_COUNT:]
79
+
80
+ page.on("console", handle_console)
81
+
82
+ async def _launch_browser(self, url: str | None = None) -> dict[str, Any]:
83
+ self.playwright = await async_playwright().start()
84
+
85
+ self.browser = await self.playwright.chromium.launch(
86
+ headless=True,
87
+ args=[
88
+ "--no-sandbox",
89
+ "--disable-dev-shm-usage",
90
+ "--disable-gpu",
91
+ "--disable-web-security",
92
+ "--disable-features=VizDisplayCompositor",
93
+ ],
94
+ )
95
+
96
+ self.context = await self.browser.new_context(
97
+ viewport={"width": 1280, "height": 720},
98
+ user_agent=(
99
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
100
+ "(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
101
+ ),
102
+ )
103
+
104
+ page = await self.context.new_page()
105
+ tab_id = f"tab_{self._next_tab_id}"
106
+ self._next_tab_id += 1
107
+ self.pages[tab_id] = page
108
+ self.current_page_id = tab_id
109
+
110
+ await self._setup_console_logging(page, tab_id)
111
+
112
+ if url:
113
+ await page.goto(url, wait_until="domcontentloaded")
114
+
115
+ return await self._get_page_state(tab_id)
116
+
117
+ async def _get_page_state(self, tab_id: str | None = None) -> dict[str, Any]:
118
+ if not tab_id:
119
+ tab_id = self.current_page_id
120
+
121
+ if not tab_id or tab_id not in self.pages:
122
+ raise ValueError(f"Tab '{tab_id}' not found")
123
+
124
+ page = self.pages[tab_id]
125
+
126
+ await asyncio.sleep(2)
127
+
128
+ screenshot_bytes = await page.screenshot(type="png", full_page=False)
129
+ screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
130
+
131
+ url = page.url
132
+ title = await page.title()
133
+ viewport = page.viewport_size
134
+
135
+ all_tabs = {}
136
+ for tid, tab_page in self.pages.items():
137
+ all_tabs[tid] = {
138
+ "url": tab_page.url,
139
+ "title": await tab_page.title() if not tab_page.is_closed() else "Closed",
140
+ }
141
+
142
+ return {
143
+ "screenshot": screenshot_b64,
144
+ "url": url,
145
+ "title": title,
146
+ "viewport": viewport,
147
+ "tab_id": tab_id,
148
+ "all_tabs": all_tabs,
149
+ }
150
+
151
+ def launch(self, url: str | None = None) -> dict[str, Any]:
152
+ with self._execution_lock:
153
+ if self.browser is not None:
154
+ raise ValueError("Browser is already launched")
155
+
156
+ return self._run_async(self._launch_browser(url))
157
+
158
+ def goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
159
+ with self._execution_lock:
160
+ return self._run_async(self._goto(url, tab_id))
161
+
162
+ async def _goto(self, url: str, tab_id: str | None = None) -> dict[str, Any]:
163
+ if not tab_id:
164
+ tab_id = self.current_page_id
165
+
166
+ if not tab_id or tab_id not in self.pages:
167
+ raise ValueError(f"Tab '{tab_id}' not found")
168
+
169
+ page = self.pages[tab_id]
170
+ await page.goto(url, wait_until="domcontentloaded")
171
+
172
+ return await self._get_page_state(tab_id)
173
+
174
+ def click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
175
+ with self._execution_lock:
176
+ return self._run_async(self._click(coordinate, tab_id))
177
+
178
+ async def _click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
179
+ if not tab_id:
180
+ tab_id = self.current_page_id
181
+
182
+ if not tab_id or tab_id not in self.pages:
183
+ raise ValueError(f"Tab '{tab_id}' not found")
184
+
185
+ try:
186
+ x, y = map(int, coordinate.split(","))
187
+ except ValueError as e:
188
+ raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
189
+
190
+ page = self.pages[tab_id]
191
+ await page.mouse.click(x, y)
192
+
193
+ return await self._get_page_state(tab_id)
194
+
195
+ def type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
196
+ with self._execution_lock:
197
+ return self._run_async(self._type_text(text, tab_id))
198
+
199
+ async def _type_text(self, text: str, tab_id: str | None = None) -> dict[str, Any]:
200
+ if not tab_id:
201
+ tab_id = self.current_page_id
202
+
203
+ if not tab_id or tab_id not in self.pages:
204
+ raise ValueError(f"Tab '{tab_id}' not found")
205
+
206
+ page = self.pages[tab_id]
207
+ await page.keyboard.type(text)
208
+
209
+ return await self._get_page_state(tab_id)
210
+
211
+ def scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
212
+ with self._execution_lock:
213
+ return self._run_async(self._scroll(direction, tab_id))
214
+
215
+ async def _scroll(self, direction: str, tab_id: str | None = None) -> dict[str, Any]:
216
+ if not tab_id:
217
+ tab_id = self.current_page_id
218
+
219
+ if not tab_id or tab_id not in self.pages:
220
+ raise ValueError(f"Tab '{tab_id}' not found")
221
+
222
+ page = self.pages[tab_id]
223
+
224
+ if direction == "down":
225
+ await page.keyboard.press("PageDown")
226
+ elif direction == "up":
227
+ await page.keyboard.press("PageUp")
228
+ else:
229
+ raise ValueError(f"Invalid scroll direction: {direction}")
230
+
231
+ return await self._get_page_state(tab_id)
232
+
233
+ def back(self, tab_id: str | None = None) -> dict[str, Any]:
234
+ with self._execution_lock:
235
+ return self._run_async(self._back(tab_id))
236
+
237
+ async def _back(self, tab_id: str | None = None) -> dict[str, Any]:
238
+ if not tab_id:
239
+ tab_id = self.current_page_id
240
+
241
+ if not tab_id or tab_id not in self.pages:
242
+ raise ValueError(f"Tab '{tab_id}' not found")
243
+
244
+ page = self.pages[tab_id]
245
+ await page.go_back(wait_until="domcontentloaded")
246
+
247
+ return await self._get_page_state(tab_id)
248
+
249
+ def forward(self, tab_id: str | None = None) -> dict[str, Any]:
250
+ with self._execution_lock:
251
+ return self._run_async(self._forward(tab_id))
252
+
253
+ async def _forward(self, tab_id: str | None = None) -> dict[str, Any]:
254
+ if not tab_id:
255
+ tab_id = self.current_page_id
256
+
257
+ if not tab_id or tab_id not in self.pages:
258
+ raise ValueError(f"Tab '{tab_id}' not found")
259
+
260
+ page = self.pages[tab_id]
261
+ await page.go_forward(wait_until="domcontentloaded")
262
+
263
+ return await self._get_page_state(tab_id)
264
+
265
+ def new_tab(self, url: str | None = None) -> dict[str, Any]:
266
+ with self._execution_lock:
267
+ return self._run_async(self._new_tab(url))
268
+
269
+ async def _new_tab(self, url: str | None = None) -> dict[str, Any]:
270
+ if not self.context:
271
+ raise ValueError("Browser not launched")
272
+
273
+ page = await self.context.new_page()
274
+ tab_id = f"tab_{self._next_tab_id}"
275
+ self._next_tab_id += 1
276
+ self.pages[tab_id] = page
277
+ self.current_page_id = tab_id
278
+
279
+ await self._setup_console_logging(page, tab_id)
280
+
281
+ if url:
282
+ await page.goto(url, wait_until="domcontentloaded")
283
+
284
+ return await self._get_page_state(tab_id)
285
+
286
+ def switch_tab(self, tab_id: str) -> dict[str, Any]:
287
+ with self._execution_lock:
288
+ return self._run_async(self._switch_tab(tab_id))
289
+
290
+ async def _switch_tab(self, tab_id: str) -> dict[str, Any]:
291
+ if tab_id not in self.pages:
292
+ raise ValueError(f"Tab '{tab_id}' not found")
293
+
294
+ self.current_page_id = tab_id
295
+ return await self._get_page_state(tab_id)
296
+
297
+ def close_tab(self, tab_id: str) -> dict[str, Any]:
298
+ with self._execution_lock:
299
+ return self._run_async(self._close_tab(tab_id))
300
+
301
+ async def _close_tab(self, tab_id: str) -> dict[str, Any]:
302
+ if tab_id not in self.pages:
303
+ raise ValueError(f"Tab '{tab_id}' not found")
304
+
305
+ if len(self.pages) == 1:
306
+ raise ValueError("Cannot close the last tab")
307
+
308
+ page = self.pages.pop(tab_id)
309
+ await page.close()
310
+
311
+ if tab_id in self.console_logs:
312
+ del self.console_logs[tab_id]
313
+
314
+ if self.current_page_id == tab_id:
315
+ self.current_page_id = next(iter(self.pages.keys()))
316
+
317
+ return await self._get_page_state(self.current_page_id)
318
+
319
+ def wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
320
+ with self._execution_lock:
321
+ return self._run_async(self._wait(duration, tab_id))
322
+
323
+ async def _wait(self, duration: float, tab_id: str | None = None) -> dict[str, Any]:
324
+ await asyncio.sleep(duration)
325
+ return await self._get_page_state(tab_id)
326
+
327
+ def execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
328
+ with self._execution_lock:
329
+ return self._run_async(self._execute_js(js_code, tab_id))
330
+
331
+ async def _execute_js(self, js_code: str, tab_id: str | None = None) -> dict[str, Any]:
332
+ if not tab_id:
333
+ tab_id = self.current_page_id
334
+
335
+ if not tab_id or tab_id not in self.pages:
336
+ raise ValueError(f"Tab '{tab_id}' not found")
337
+
338
+ page = self.pages[tab_id]
339
+
340
+ try:
341
+ result = await page.evaluate(js_code)
342
+ except Exception as e: # noqa: BLE001
343
+ result = {
344
+ "error": True,
345
+ "error_type": type(e).__name__,
346
+ "error_message": str(e),
347
+ }
348
+
349
+ result_str = str(result)
350
+ if len(result_str) > MAX_JS_RESULT_LENGTH:
351
+ result = result_str[:MAX_JS_RESULT_LENGTH] + "... [JS result truncated at 5k chars]"
352
+
353
+ state = await self._get_page_state(tab_id)
354
+ state["js_result"] = result
355
+ return state
356
+
357
+ def get_console_logs(self, tab_id: str | None = None, clear: bool = False) -> dict[str, Any]:
358
+ with self._execution_lock:
359
+ return self._run_async(self._get_console_logs(tab_id, clear))
360
+
361
+ async def _get_console_logs(
362
+ self, tab_id: str | None = None, clear: bool = False
363
+ ) -> dict[str, Any]:
364
+ if not tab_id:
365
+ tab_id = self.current_page_id
366
+
367
+ if not tab_id or tab_id not in self.pages:
368
+ raise ValueError(f"Tab '{tab_id}' not found")
369
+
370
+ logs = self.console_logs.get(tab_id, [])
371
+
372
+ total_length = sum(len(str(log)) for log in logs)
373
+ if total_length > MAX_CONSOLE_LOG_LENGTH:
374
+ truncated_logs: list[dict[str, Any]] = []
375
+ current_length = 0
376
+
377
+ for log in reversed(logs):
378
+ log_length = len(str(log))
379
+ if current_length + log_length <= MAX_CONSOLE_LOG_LENGTH:
380
+ truncated_logs.insert(0, log)
381
+ current_length += log_length
382
+ else:
383
+ break
384
+
385
+ if len(truncated_logs) < len(logs):
386
+ truncation_notice = {
387
+ "type": "info",
388
+ "text": (
389
+ f"[TRUNCATED: {len(logs) - len(truncated_logs)} older logs "
390
+ f"removed to stay within {MAX_CONSOLE_LOG_LENGTH} character limit]"
391
+ ),
392
+ "location": {},
393
+ "timestamp": 0,
394
+ }
395
+ truncated_logs.insert(0, truncation_notice)
396
+
397
+ logs = truncated_logs
398
+
399
+ if clear:
400
+ self.console_logs[tab_id] = []
401
+
402
+ state = await self._get_page_state(tab_id)
403
+ state["console_logs"] = logs
404
+ return state
405
+
406
+ def view_source(self, tab_id: str | None = None) -> dict[str, Any]:
407
+ with self._execution_lock:
408
+ return self._run_async(self._view_source(tab_id))
409
+
410
+ async def _view_source(self, tab_id: str | None = None) -> dict[str, Any]:
411
+ if not tab_id:
412
+ tab_id = self.current_page_id
413
+
414
+ if not tab_id or tab_id not in self.pages:
415
+ raise ValueError(f"Tab '{tab_id}' not found")
416
+
417
+ page = self.pages[tab_id]
418
+ source = await page.content()
419
+ original_length = len(source)
420
+
421
+ if original_length > MAX_PAGE_SOURCE_LENGTH:
422
+ truncation_message = (
423
+ f"\n\n<!-- [TRUNCATED: {original_length - MAX_PAGE_SOURCE_LENGTH} "
424
+ "characters removed] -->\n\n"
425
+ )
426
+ available_space = MAX_PAGE_SOURCE_LENGTH - len(truncation_message)
427
+ truncate_point = available_space // 2
428
+
429
+ source = source[:truncate_point] + truncation_message + source[-truncate_point:]
430
+
431
+ state = await self._get_page_state(tab_id)
432
+ state["page_source"] = source
433
+ return state
434
+
435
+ def double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
436
+ with self._execution_lock:
437
+ return self._run_async(self._double_click(coordinate, tab_id))
438
+
439
+ async def _double_click(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
440
+ if not tab_id:
441
+ tab_id = self.current_page_id
442
+
443
+ if not tab_id or tab_id not in self.pages:
444
+ raise ValueError(f"Tab '{tab_id}' not found")
445
+
446
+ try:
447
+ x, y = map(int, coordinate.split(","))
448
+ except ValueError as e:
449
+ raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
450
+
451
+ page = self.pages[tab_id]
452
+ await page.mouse.dblclick(x, y)
453
+
454
+ return await self._get_page_state(tab_id)
455
+
456
+ def hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
457
+ with self._execution_lock:
458
+ return self._run_async(self._hover(coordinate, tab_id))
459
+
460
+ async def _hover(self, coordinate: str, tab_id: str | None = None) -> dict[str, Any]:
461
+ if not tab_id:
462
+ tab_id = self.current_page_id
463
+
464
+ if not tab_id or tab_id not in self.pages:
465
+ raise ValueError(f"Tab '{tab_id}' not found")
466
+
467
+ try:
468
+ x, y = map(int, coordinate.split(","))
469
+ except ValueError as e:
470
+ raise ValueError(f"Invalid coordinate format: {coordinate}. Use 'x,y'") from e
471
+
472
+ page = self.pages[tab_id]
473
+ await page.mouse.move(x, y)
474
+
475
+ return await self._get_page_state(tab_id)
476
+
477
+ def press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
478
+ with self._execution_lock:
479
+ return self._run_async(self._press_key(key, tab_id))
480
+
481
+ async def _press_key(self, key: str, tab_id: str | None = None) -> dict[str, Any]:
482
+ if not tab_id:
483
+ tab_id = self.current_page_id
484
+
485
+ if not tab_id or tab_id not in self.pages:
486
+ raise ValueError(f"Tab '{tab_id}' not found")
487
+
488
+ page = self.pages[tab_id]
489
+ await page.keyboard.press(key)
490
+
491
+ return await self._get_page_state(tab_id)
492
+
493
+ def save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
494
+ with self._execution_lock:
495
+ return self._run_async(self._save_pdf(file_path, tab_id))
496
+
497
+ async def _save_pdf(self, file_path: str, tab_id: str | None = None) -> dict[str, Any]:
498
+ if not tab_id:
499
+ tab_id = self.current_page_id
500
+
501
+ if not tab_id or tab_id not in self.pages:
502
+ raise ValueError(f"Tab '{tab_id}' not found")
503
+
504
+ if not Path(file_path).is_absolute():
505
+ file_path = str(Path("/workspace") / file_path)
506
+
507
+ page = self.pages[tab_id]
508
+ await page.pdf(path=file_path)
509
+
510
+ state = await self._get_page_state(tab_id)
511
+ state["pdf_saved"] = file_path
512
+ return state
513
+
514
+ def close(self) -> None:
515
+ with self._execution_lock:
516
+ self.is_running = False
517
+ if self._loop:
518
+ asyncio.run_coroutine_threadsafe(self._close_browser(), self._loop)
519
+
520
+ self._loop.call_soon_threadsafe(self._loop.stop)
521
+
522
+ if self._loop_thread:
523
+ self._loop_thread.join(timeout=5)
524
+
525
+ async def _close_browser(self) -> None:
526
+ try:
527
+ if self.browser:
528
+ await self.browser.close()
529
+ if self.playwright:
530
+ await self.playwright.stop()
531
+ except (OSError, RuntimeError) as e:
532
+ logger.warning(f"Error closing browser: {e}")
533
+
534
+ def is_alive(self) -> bool:
535
+ return self.is_running and self.browser is not None and self.browser.is_connected()