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.
- strix/__init__.py +0 -0
- strix/agents/StrixAgent/__init__.py +4 -0
- strix/agents/StrixAgent/strix_agent.py +60 -0
- strix/agents/StrixAgent/system_prompt.jinja +504 -0
- strix/agents/__init__.py +10 -0
- strix/agents/base_agent.py +394 -0
- strix/agents/state.py +139 -0
- strix/cli/__init__.py +4 -0
- strix/cli/app.py +1124 -0
- strix/cli/assets/cli.tcss +680 -0
- strix/cli/main.py +542 -0
- strix/cli/tool_components/__init__.py +39 -0
- strix/cli/tool_components/agents_graph_renderer.py +129 -0
- strix/cli/tool_components/base_renderer.py +61 -0
- strix/cli/tool_components/browser_renderer.py +107 -0
- strix/cli/tool_components/file_edit_renderer.py +95 -0
- strix/cli/tool_components/finish_renderer.py +32 -0
- strix/cli/tool_components/notes_renderer.py +108 -0
- strix/cli/tool_components/proxy_renderer.py +255 -0
- strix/cli/tool_components/python_renderer.py +34 -0
- strix/cli/tool_components/registry.py +72 -0
- strix/cli/tool_components/reporting_renderer.py +53 -0
- strix/cli/tool_components/scan_info_renderer.py +58 -0
- strix/cli/tool_components/terminal_renderer.py +99 -0
- strix/cli/tool_components/thinking_renderer.py +29 -0
- strix/cli/tool_components/user_message_renderer.py +43 -0
- strix/cli/tool_components/web_search_renderer.py +28 -0
- strix/cli/tracer.py +308 -0
- strix/llm/__init__.py +14 -0
- strix/llm/config.py +19 -0
- strix/llm/llm.py +310 -0
- strix/llm/memory_compressor.py +206 -0
- strix/llm/request_queue.py +63 -0
- strix/llm/utils.py +84 -0
- strix/prompts/__init__.py +113 -0
- strix/prompts/coordination/root_agent.jinja +41 -0
- strix/prompts/vulnerabilities/authentication_jwt.jinja +129 -0
- strix/prompts/vulnerabilities/business_logic.jinja +143 -0
- strix/prompts/vulnerabilities/csrf.jinja +168 -0
- strix/prompts/vulnerabilities/idor.jinja +164 -0
- strix/prompts/vulnerabilities/race_conditions.jinja +194 -0
- strix/prompts/vulnerabilities/rce.jinja +222 -0
- strix/prompts/vulnerabilities/sql_injection.jinja +216 -0
- strix/prompts/vulnerabilities/ssrf.jinja +168 -0
- strix/prompts/vulnerabilities/xss.jinja +221 -0
- strix/prompts/vulnerabilities/xxe.jinja +276 -0
- strix/runtime/__init__.py +19 -0
- strix/runtime/docker_runtime.py +298 -0
- strix/runtime/runtime.py +25 -0
- strix/runtime/tool_server.py +97 -0
- strix/tools/__init__.py +64 -0
- strix/tools/agents_graph/__init__.py +16 -0
- strix/tools/agents_graph/agents_graph_actions.py +610 -0
- strix/tools/agents_graph/agents_graph_actions_schema.xml +223 -0
- strix/tools/argument_parser.py +120 -0
- strix/tools/browser/__init__.py +4 -0
- strix/tools/browser/browser_actions.py +236 -0
- strix/tools/browser/browser_actions_schema.xml +183 -0
- strix/tools/browser/browser_instance.py +533 -0
- strix/tools/browser/tab_manager.py +342 -0
- strix/tools/executor.py +302 -0
- strix/tools/file_edit/__init__.py +4 -0
- strix/tools/file_edit/file_edit_actions.py +141 -0
- strix/tools/file_edit/file_edit_actions_schema.xml +128 -0
- strix/tools/finish/__init__.py +4 -0
- strix/tools/finish/finish_actions.py +167 -0
- strix/tools/finish/finish_actions_schema.xml +45 -0
- strix/tools/notes/__init__.py +14 -0
- strix/tools/notes/notes_actions.py +191 -0
- strix/tools/notes/notes_actions_schema.xml +150 -0
- strix/tools/proxy/__init__.py +20 -0
- strix/tools/proxy/proxy_actions.py +101 -0
- strix/tools/proxy/proxy_actions_schema.xml +267 -0
- strix/tools/proxy/proxy_manager.py +785 -0
- strix/tools/python/__init__.py +4 -0
- strix/tools/python/python_actions.py +47 -0
- strix/tools/python/python_actions_schema.xml +131 -0
- strix/tools/python/python_instance.py +172 -0
- strix/tools/python/python_manager.py +131 -0
- strix/tools/registry.py +196 -0
- strix/tools/reporting/__init__.py +6 -0
- strix/tools/reporting/reporting_actions.py +63 -0
- strix/tools/reporting/reporting_actions_schema.xml +30 -0
- strix/tools/terminal/__init__.py +4 -0
- strix/tools/terminal/terminal_actions.py +53 -0
- strix/tools/terminal/terminal_actions_schema.xml +114 -0
- strix/tools/terminal/terminal_instance.py +231 -0
- strix/tools/terminal/terminal_manager.py +191 -0
- strix/tools/thinking/__init__.py +4 -0
- strix/tools/thinking/thinking_actions.py +18 -0
- strix/tools/thinking/thinking_actions_schema.xml +52 -0
- strix/tools/web_search/__init__.py +4 -0
- strix/tools/web_search/web_search_actions.py +80 -0
- strix/tools/web_search/web_search_actions_schema.xml +83 -0
- strix_agent-0.1.1.dist-info/LICENSE +201 -0
- strix_agent-0.1.1.dist-info/METADATA +200 -0
- strix_agent-0.1.1.dist-info/RECORD +99 -0
- strix_agent-0.1.1.dist-info/WHEEL +4 -0
- 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()
|