leashbyte 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.
- leashbyte/__init__.py +3 -0
- leashbyte/agent_worker.py +488 -0
- leashbyte/browser.py +627 -0
- leashbyte/browser_service.py +241 -0
- leashbyte/config.py +186 -0
- leashbyte/errors.py +11 -0
- leashbyte/fetch.py +333 -0
- leashbyte/internal.py +58 -0
- leashbyte/main.py +470 -0
- leashbyte/mcp/__init__.py +1 -0
- leashbyte/mcp/server.py +290 -0
- leashbyte/model_adapter.py +206 -0
- leashbyte/model_relay.py +84 -0
- leashbyte/models.py +142 -0
- leashbyte/proxy.py +179 -0
- leashbyte/run_models.py +164 -0
- leashbyte/run_store.py +493 -0
- leashbyte/run_viewer.py +42 -0
- leashbyte/search.py +103 -0
- leashbyte/url_guard.py +137 -0
- leashbyte/vault.py +210 -0
- leashbyte/vault_service.py +136 -0
- leashbyte/worker_service.py +19 -0
- leashbyte-0.1.1.dist-info/METADATA +456 -0
- leashbyte-0.1.1.dist-info/RECORD +29 -0
- leashbyte-0.1.1.dist-info/WHEEL +4 -0
- leashbyte-0.1.1.dist-info/entry_points.txt +2 -0
- leashbyte-0.1.1.dist-info/licenses/LICENSE +203 -0
- leashbyte-0.1.1.dist-info/licenses/NOTICE +29 -0
leashbyte/browser.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Chrome lifecycle, marked observations, request controls, and CDP grants."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import html
|
|
9
|
+
import importlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import quote, quote_plus, urlsplit
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import websockets
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
23
|
+
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
24
|
+
from playwright.async_api import BrowserContext, ElementHandle, Route, async_playwright
|
|
25
|
+
from playwright.async_api import Error as PlaywrightError
|
|
26
|
+
from playwright.async_api import Request as PlaywrightRequest
|
|
27
|
+
|
|
28
|
+
from .config import Settings
|
|
29
|
+
from .errors import LeashByteError
|
|
30
|
+
from .fetch import Fetcher, HostThrottle
|
|
31
|
+
from .models import BrowserActionRequest
|
|
32
|
+
from .proxy import GuardedProxy
|
|
33
|
+
from .url_guard import URLGuard
|
|
34
|
+
from .vault import https_page_origin, placeholder_parts
|
|
35
|
+
|
|
36
|
+
USER_AGENT = "LeashByte/0.1 (+https://leashbyte.com)"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProfileLease:
|
|
40
|
+
"""An advisory lock that works across browser-pool processes."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, root: Path, profile: str) -> None:
|
|
43
|
+
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
44
|
+
self.path = root / f"{profile}.lock"
|
|
45
|
+
self.handle = self.path.open("a+b")
|
|
46
|
+
os.chmod(self.path, 0o600)
|
|
47
|
+
try:
|
|
48
|
+
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
49
|
+
except BlockingIOError as error:
|
|
50
|
+
self.handle.close()
|
|
51
|
+
raise LeashByteError(
|
|
52
|
+
409, "profile_in_use", "The profile is in another live session."
|
|
53
|
+
) from error
|
|
54
|
+
|
|
55
|
+
def release(self) -> None:
|
|
56
|
+
if self.handle.closed:
|
|
57
|
+
return
|
|
58
|
+
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
|
59
|
+
self.handle.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CDPGrantStore:
|
|
63
|
+
"""One-time, short-lived CDP credentials stored as hashes."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, ttl_seconds: int) -> None:
|
|
66
|
+
self.ttl_seconds = ttl_seconds
|
|
67
|
+
self.grants: dict[tuple[str, str], float] = {}
|
|
68
|
+
|
|
69
|
+
def issue(self, session_id: str) -> tuple[str, int]:
|
|
70
|
+
now = time.time()
|
|
71
|
+
self.grants = {key: expiry for key, expiry in self.grants.items() if expiry > now}
|
|
72
|
+
token = secrets.token_urlsafe(32)
|
|
73
|
+
digest = hashlib.sha256(token.encode("ascii")).hexdigest()
|
|
74
|
+
self.grants[(session_id, digest)] = now + self.ttl_seconds
|
|
75
|
+
return token, self.ttl_seconds
|
|
76
|
+
|
|
77
|
+
def consume(self, session_id: str, token: str, now: float | None = None) -> bool:
|
|
78
|
+
now = time.time() if now is None else now
|
|
79
|
+
try:
|
|
80
|
+
digest = hashlib.sha256(token.encode("ascii")).hexdigest()
|
|
81
|
+
except UnicodeEncodeError:
|
|
82
|
+
return False
|
|
83
|
+
expires = self.grants.get((session_id, digest))
|
|
84
|
+
if expires is None or expires <= now:
|
|
85
|
+
return False
|
|
86
|
+
del self.grants[(session_id, digest)]
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class WebBotAuthSigner:
|
|
91
|
+
"""Optional HTTP message signatures for the identified bot."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, settings: Settings) -> None:
|
|
94
|
+
self.key = None
|
|
95
|
+
self.key_id = settings.web_bot_auth_key_id
|
|
96
|
+
self.agent_url = settings.web_bot_auth_agent_url
|
|
97
|
+
if settings.web_bot_auth_key_file is None:
|
|
98
|
+
return
|
|
99
|
+
if re.fullmatch(r"[A-Za-z0-9._-]{1,128}", self.key_id) is None:
|
|
100
|
+
raise RuntimeError("LEASHBYTE_WEB_BOT_AUTH_KEY_ID is not valid.")
|
|
101
|
+
key = load_pem_private_key(settings.web_bot_auth_key_file.read_bytes(), password=None)
|
|
102
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
103
|
+
raise RuntimeError("The Web Bot Auth key must be an Ed25519 private key.")
|
|
104
|
+
self.key = key
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def enabled(self) -> bool:
|
|
108
|
+
return self.key is not None
|
|
109
|
+
|
|
110
|
+
def headers(self, method: str, url: str) -> dict[str, str]:
|
|
111
|
+
if self.key is None:
|
|
112
|
+
return {}
|
|
113
|
+
parts = urlsplit(url)
|
|
114
|
+
authority = parts.netloc
|
|
115
|
+
created = int(time.time())
|
|
116
|
+
parameters = (
|
|
117
|
+
'("@method" "@authority" "@target-uri")'
|
|
118
|
+
f';created={created};expires={created + 300};keyid="{self.key_id}";tag="web-bot-auth"'
|
|
119
|
+
)
|
|
120
|
+
covered = (
|
|
121
|
+
f'"@method": {method}\n"@authority": {authority}\n"@target-uri": {url}\n'
|
|
122
|
+
f'"@signature-params": {parameters}'
|
|
123
|
+
)
|
|
124
|
+
signature = self.key.sign(covered.encode("utf-8"))
|
|
125
|
+
return {
|
|
126
|
+
"Signature-Agent": f'"{self.agent_url}"',
|
|
127
|
+
"Signature-Input": f"sig1={parameters}",
|
|
128
|
+
"Signature": f"sig1=:{base64.b64encode(signature).decode('ascii')}:",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Session:
|
|
134
|
+
id: str
|
|
135
|
+
profile: str
|
|
136
|
+
context: BrowserContext
|
|
137
|
+
lease: ProfileLease
|
|
138
|
+
expires_at: float
|
|
139
|
+
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
140
|
+
latest_snapshot: str = ""
|
|
141
|
+
marks: dict[int, ElementHandle] = field(default_factory=dict)
|
|
142
|
+
redactions: list[str] = field(default_factory=list)
|
|
143
|
+
cdp_issued: bool = False
|
|
144
|
+
closed: bool = False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class BrowserPool:
|
|
148
|
+
def __init__(self, settings: Settings) -> None:
|
|
149
|
+
self.settings = settings
|
|
150
|
+
self.guard = URLGuard(settings.allowlist())
|
|
151
|
+
self.proxy = GuardedProxy(self.guard, settings.fetch_connect_timeout_seconds)
|
|
152
|
+
self.fetcher = Fetcher(settings)
|
|
153
|
+
self.throttle = HostThrottle(settings.fetch_min_host_interval_seconds)
|
|
154
|
+
self.sessions: dict[str, Session] = {}
|
|
155
|
+
self.guard_lock = asyncio.Lock()
|
|
156
|
+
self.playwright = None
|
|
157
|
+
self.grants = CDPGrantStore(settings.cdp_grant_seconds)
|
|
158
|
+
self.signer = WebBotAuthSigner(settings)
|
|
159
|
+
self.plugins = []
|
|
160
|
+
if settings.browser_plugins_enabled:
|
|
161
|
+
for name in settings.browser_plugin_modules.split(","):
|
|
162
|
+
if name.strip():
|
|
163
|
+
self.plugins.append(importlib.import_module(name.strip()))
|
|
164
|
+
|
|
165
|
+
async def start(self) -> None:
|
|
166
|
+
await self.proxy.start()
|
|
167
|
+
self.playwright = await async_playwright().start()
|
|
168
|
+
|
|
169
|
+
async def stop(self) -> None:
|
|
170
|
+
for session_id in list(self.sessions):
|
|
171
|
+
await self.close(session_id, missing_ok=True)
|
|
172
|
+
if self.playwright is not None:
|
|
173
|
+
await self.playwright.stop()
|
|
174
|
+
await self.proxy.stop()
|
|
175
|
+
|
|
176
|
+
async def reap(self) -> None:
|
|
177
|
+
now = time.time()
|
|
178
|
+
for session_id, session in list(self.sessions.items()):
|
|
179
|
+
if session.expires_at <= now:
|
|
180
|
+
await self.close(session_id, missing_ok=True)
|
|
181
|
+
|
|
182
|
+
async def create(
|
|
183
|
+
self, profile: str, start_url: str | None, proxy: str | None, lease_seconds: int | None
|
|
184
|
+
) -> dict:
|
|
185
|
+
async with self.guard_lock:
|
|
186
|
+
if len(self.sessions) >= self.settings.browser_max_sessions:
|
|
187
|
+
raise LeashByteError(429, "browser_capacity", "The browser pool is full.")
|
|
188
|
+
if any(session.profile == profile for session in self.sessions.values()):
|
|
189
|
+
raise LeashByteError(
|
|
190
|
+
409, "profile_in_use", "The profile is in another live session."
|
|
191
|
+
)
|
|
192
|
+
if proxy is not None:
|
|
193
|
+
raise LeashByteError(
|
|
194
|
+
422,
|
|
195
|
+
"proxy_not_supported",
|
|
196
|
+
"Custom proxies are not supported. The browser uses its own guarded proxy.",
|
|
197
|
+
)
|
|
198
|
+
lease = ProfileLease(self.settings.browser_profile_root, profile)
|
|
199
|
+
try:
|
|
200
|
+
duration = min(
|
|
201
|
+
lease_seconds or self.settings.browser_lease_seconds,
|
|
202
|
+
self.settings.browser_lease_seconds,
|
|
203
|
+
)
|
|
204
|
+
args = [
|
|
205
|
+
"--remote-debugging-address=127.0.0.1",
|
|
206
|
+
"--remote-debugging-port=0",
|
|
207
|
+
"--disable-background-networking",
|
|
208
|
+
"--disable-component-update",
|
|
209
|
+
"--disable-dev-shm-usage",
|
|
210
|
+
"--disable-quic",
|
|
211
|
+
"--disable-sync",
|
|
212
|
+
"--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
|
|
213
|
+
"--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1",
|
|
214
|
+
"--metrics-recording-only",
|
|
215
|
+
"--no-default-browser-check",
|
|
216
|
+
"--no-first-run",
|
|
217
|
+
"--proxy-bypass-list=<-loopback>",
|
|
218
|
+
]
|
|
219
|
+
spkis = [
|
|
220
|
+
item.strip()
|
|
221
|
+
for item in self.settings.browser_tls_spki_allowlist.split(",")
|
|
222
|
+
if item.strip()
|
|
223
|
+
]
|
|
224
|
+
if spkis:
|
|
225
|
+
args.append(f"--ignore-certificate-errors-spki-list={','.join(spkis)}")
|
|
226
|
+
proxy_settings = {"server": f"http://127.0.0.1:{self.proxy.port}"}
|
|
227
|
+
context = await self.playwright.chromium.launch_persistent_context(
|
|
228
|
+
user_data_dir=str(self.settings.browser_profile_root / profile),
|
|
229
|
+
channel="chrome",
|
|
230
|
+
headless=True,
|
|
231
|
+
chromium_sandbox=False,
|
|
232
|
+
accept_downloads=False,
|
|
233
|
+
proxy=proxy_settings,
|
|
234
|
+
args=args,
|
|
235
|
+
user_agent=USER_AGENT,
|
|
236
|
+
service_workers="block",
|
|
237
|
+
env={
|
|
238
|
+
"HOME": "/tmp", # noqa: S108 - private container tmpfs
|
|
239
|
+
"LANG": "C.UTF-8",
|
|
240
|
+
"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
|
241
|
+
"TMPDIR": "/tmp", # noqa: S108 - private container tmpfs
|
|
242
|
+
"TZ": "UTC",
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
session = Session(
|
|
246
|
+
id=uuid.uuid4().hex,
|
|
247
|
+
profile=profile,
|
|
248
|
+
context=context,
|
|
249
|
+
lease=lease,
|
|
250
|
+
expires_at=time.time() + duration,
|
|
251
|
+
)
|
|
252
|
+
self.sessions[session.id] = session
|
|
253
|
+
|
|
254
|
+
def release_closed_context() -> None:
|
|
255
|
+
if not session.closed:
|
|
256
|
+
session.closed = True
|
|
257
|
+
session.lease.release()
|
|
258
|
+
self.sessions.pop(session.id, None)
|
|
259
|
+
|
|
260
|
+
context.on("close", release_closed_context)
|
|
261
|
+
await context.route(
|
|
262
|
+
"**/*", lambda route, request: self.intercept(session, route, request)
|
|
263
|
+
)
|
|
264
|
+
if start_url:
|
|
265
|
+
await self.navigate(session, start_url)
|
|
266
|
+
await self.snapshot(session)
|
|
267
|
+
return self.describe(session)
|
|
268
|
+
except BaseException:
|
|
269
|
+
if "session" in locals():
|
|
270
|
+
await self.close(session.id, missing_ok=True)
|
|
271
|
+
else:
|
|
272
|
+
lease.release()
|
|
273
|
+
raise
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def redact(session: Session, value: str) -> str:
|
|
277
|
+
for secret in session.redactions:
|
|
278
|
+
encoded = quote(secret, safe="")
|
|
279
|
+
form_encoded = quote_plus(secret, safe="")
|
|
280
|
+
lower_escapes = re.sub(r"%[0-9A-F]{2}", lambda match: match.group().lower(), encoded)
|
|
281
|
+
variants = {
|
|
282
|
+
secret,
|
|
283
|
+
html.escape(secret),
|
|
284
|
+
html.escape(secret, quote=False),
|
|
285
|
+
encoded,
|
|
286
|
+
form_encoded,
|
|
287
|
+
lower_escapes,
|
|
288
|
+
encoded.lower(),
|
|
289
|
+
}
|
|
290
|
+
for variant in variants:
|
|
291
|
+
value = re.sub(re.escape(variant), "[redacted]", value, flags=re.IGNORECASE)
|
|
292
|
+
return value
|
|
293
|
+
|
|
294
|
+
def describe(self, session: Session) -> dict:
|
|
295
|
+
page = session.context.pages[0] if session.context.pages else None
|
|
296
|
+
return {
|
|
297
|
+
"id": session.id,
|
|
298
|
+
"profile": session.profile,
|
|
299
|
+
"state": "closed" if session.closed else "active",
|
|
300
|
+
"current_url": self.redact(session, page.url) if page else "about:blank",
|
|
301
|
+
"expires_at": datetime.fromtimestamp(session.expires_at, UTC).isoformat(),
|
|
302
|
+
"capabilities": ["snapshot", "actions", "screenshot", "cdp"],
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
def get(self, session_id: str) -> Session:
|
|
306
|
+
session = self.sessions.get(session_id)
|
|
307
|
+
if session is None or session.closed:
|
|
308
|
+
raise LeashByteError(404, "session_not_found", "The browser session was not found.")
|
|
309
|
+
if session.expires_at <= time.time():
|
|
310
|
+
raise LeashByteError(410, "session_expired", "The browser session lease expired.")
|
|
311
|
+
return session
|
|
312
|
+
|
|
313
|
+
async def close(self, session_id: str, missing_ok: bool = False) -> None:
|
|
314
|
+
session = self.sessions.get(session_id)
|
|
315
|
+
if session is None:
|
|
316
|
+
if missing_ok:
|
|
317
|
+
return
|
|
318
|
+
raise LeashByteError(404, "session_not_found", "The browser session was not found.")
|
|
319
|
+
async with session.lock:
|
|
320
|
+
if not session.closed:
|
|
321
|
+
session.closed = True
|
|
322
|
+
try:
|
|
323
|
+
await session.context.close()
|
|
324
|
+
finally:
|
|
325
|
+
session.lease.release()
|
|
326
|
+
self.sessions.pop(session_id, None)
|
|
327
|
+
|
|
328
|
+
async def snapshot(self, session: Session) -> dict:
|
|
329
|
+
page = session.context.pages[-1]
|
|
330
|
+
marks: dict[int, ElementHandle] = {}
|
|
331
|
+
nodes = []
|
|
332
|
+
elements = await page.query_selector_all(
|
|
333
|
+
"a[href],button,input,select,textarea,[role='button'],[role='link'],[tabindex]"
|
|
334
|
+
)
|
|
335
|
+
for element in elements[:200]:
|
|
336
|
+
try:
|
|
337
|
+
if not await element.is_visible() or not await element.is_enabled():
|
|
338
|
+
continue
|
|
339
|
+
details = await element.evaluate(
|
|
340
|
+
"""element => {
|
|
341
|
+
const tag = element.tagName.toLowerCase();
|
|
342
|
+
const text = (
|
|
343
|
+
element.getAttribute('aria-label') || element.innerText ||
|
|
344
|
+
element.getAttribute('placeholder') || element.getAttribute('name') || ''
|
|
345
|
+
).trim();
|
|
346
|
+
const roles = {
|
|
347
|
+
a: 'link', button: 'button', input: 'textbox',
|
|
348
|
+
select: 'combobox', textarea: 'textbox'
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
tag,
|
|
352
|
+
role: element.getAttribute('role') || (roles[tag] || tag),
|
|
353
|
+
name: text.slice(0, 300),
|
|
354
|
+
input_type: tag === 'input' ? element.type : null
|
|
355
|
+
};
|
|
356
|
+
}"""
|
|
357
|
+
)
|
|
358
|
+
mark = len(marks) + 1
|
|
359
|
+
marks[mark] = element
|
|
360
|
+
nodes.append(
|
|
361
|
+
{"mark": mark, **details, "name": self.redact(session, details["name"])}
|
|
362
|
+
)
|
|
363
|
+
except PlaywrightError:
|
|
364
|
+
continue
|
|
365
|
+
session.marks = marks
|
|
366
|
+
session.latest_snapshot = uuid.uuid4().hex
|
|
367
|
+
return {
|
|
368
|
+
"session_id": session.id,
|
|
369
|
+
"snapshot_id": session.latest_snapshot,
|
|
370
|
+
"url": self.redact(session, page.url),
|
|
371
|
+
"title": self.redact(session, await page.title()),
|
|
372
|
+
"nodes": nodes,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async def intercept(self, session: Session, route: Route, request: PlaywrightRequest) -> None:
|
|
376
|
+
url = request.url
|
|
377
|
+
if url.startswith(("about:", "data:", "blob:", "chrome:")):
|
|
378
|
+
await route.continue_()
|
|
379
|
+
return
|
|
380
|
+
try:
|
|
381
|
+
target = await self.guard.resolve(url)
|
|
382
|
+
if request.is_navigation_request():
|
|
383
|
+
if self.settings.respect_robots:
|
|
384
|
+
await self.fetcher.check_robots(
|
|
385
|
+
target.url,
|
|
386
|
+
self.settings.browser_navigation_timeout_seconds,
|
|
387
|
+
512_000,
|
|
388
|
+
)
|
|
389
|
+
await self.throttle.wait(target.host)
|
|
390
|
+
headers = {
|
|
391
|
+
**await request.all_headers(),
|
|
392
|
+
**self.signer.headers(request.method, target.url),
|
|
393
|
+
}
|
|
394
|
+
for plugin in self.plugins:
|
|
395
|
+
hook = getattr(plugin, "before_request", None)
|
|
396
|
+
if hook is not None:
|
|
397
|
+
headers = hook(headers=headers, url=target.url, method=request.method)
|
|
398
|
+
await route.continue_(headers=headers)
|
|
399
|
+
except Exception:
|
|
400
|
+
await route.abort("blockedbyclient")
|
|
401
|
+
|
|
402
|
+
async def navigate(self, session: Session, raw_url: str) -> None:
|
|
403
|
+
target = await self.guard.resolve(raw_url)
|
|
404
|
+
if self.settings.respect_robots:
|
|
405
|
+
await self.fetcher.check_robots(
|
|
406
|
+
target.url,
|
|
407
|
+
self.settings.browser_navigation_timeout_seconds,
|
|
408
|
+
512_000,
|
|
409
|
+
)
|
|
410
|
+
try:
|
|
411
|
+
page = session.context.pages[-1]
|
|
412
|
+
response = await page.goto(
|
|
413
|
+
target.url,
|
|
414
|
+
wait_until="domcontentloaded",
|
|
415
|
+
timeout=self.settings.browser_navigation_timeout_seconds * 1000,
|
|
416
|
+
)
|
|
417
|
+
if response is not None and response.status >= 400:
|
|
418
|
+
raise LeashByteError(
|
|
419
|
+
502, "navigation_failed", f"The destination returned HTTP {response.status}."
|
|
420
|
+
)
|
|
421
|
+
except PlaywrightError as error:
|
|
422
|
+
raise LeashByteError(
|
|
423
|
+
403, "browser_request_denied", "The browser could not open this destination."
|
|
424
|
+
) from error
|
|
425
|
+
|
|
426
|
+
async def act(self, session: Session, body: BrowserActionRequest) -> dict:
|
|
427
|
+
async with session.lock:
|
|
428
|
+
if body.snapshot_id != session.latest_snapshot:
|
|
429
|
+
raise LeashByteError(
|
|
430
|
+
409, "stale_snapshot", "Take a new snapshot before the next action."
|
|
431
|
+
)
|
|
432
|
+
if body.action == "navigate":
|
|
433
|
+
if not body.url:
|
|
434
|
+
raise LeashByteError(422, "invalid_action", "Navigation needs a URL.")
|
|
435
|
+
await self.navigate(session, body.url)
|
|
436
|
+
elif body.action == "scroll":
|
|
437
|
+
await session.context.pages[-1].mouse.wheel(body.x, body.y)
|
|
438
|
+
await asyncio.sleep(0.1)
|
|
439
|
+
else:
|
|
440
|
+
if body.mark is None or body.mark not in session.marks:
|
|
441
|
+
raise LeashByteError(
|
|
442
|
+
422, "invalid_mark", "The action needs a mark from the current snapshot."
|
|
443
|
+
)
|
|
444
|
+
element = session.marks[body.mark]
|
|
445
|
+
try:
|
|
446
|
+
if body.action == "click":
|
|
447
|
+
await element.click()
|
|
448
|
+
elif body.action == "type":
|
|
449
|
+
if body.text is None or "{{credential:" in body.text:
|
|
450
|
+
raise LeashByteError(
|
|
451
|
+
422,
|
|
452
|
+
"invalid_action",
|
|
453
|
+
"Use public text or a credential placeholder action.",
|
|
454
|
+
)
|
|
455
|
+
await element.type(body.text)
|
|
456
|
+
elif body.action == "select":
|
|
457
|
+
if not body.values:
|
|
458
|
+
raise LeashByteError(
|
|
459
|
+
422, "invalid_action", "Select needs at least one option value."
|
|
460
|
+
)
|
|
461
|
+
await element.select_option(body.values)
|
|
462
|
+
elif body.action == "fill_secret":
|
|
463
|
+
if body.placeholder is None:
|
|
464
|
+
raise LeashByteError(
|
|
465
|
+
422, "invalid_action", "Secret fill needs a credential placeholder."
|
|
466
|
+
)
|
|
467
|
+
await self.fill_secret(session, element, body.placeholder)
|
|
468
|
+
except PlaywrightError as error:
|
|
469
|
+
raise LeashByteError(
|
|
470
|
+
409, "action_failed", "The marked element did not accept the action."
|
|
471
|
+
) from error
|
|
472
|
+
return await self.snapshot(session)
|
|
473
|
+
|
|
474
|
+
async def fill_secret(self, session: Session, element: ElementHandle, placeholder: str) -> None:
|
|
475
|
+
if session.cdp_issued:
|
|
476
|
+
raise LeashByteError(
|
|
477
|
+
409,
|
|
478
|
+
"cdp_fill_conflict",
|
|
479
|
+
"Use a different session for CDP or credential fills.",
|
|
480
|
+
)
|
|
481
|
+
alias, field_name = placeholder_parts(placeholder)
|
|
482
|
+
frame = await element.owner_frame()
|
|
483
|
+
if frame is None or frame.parent_frame is not None:
|
|
484
|
+
raise LeashByteError(
|
|
485
|
+
422, "invalid_secret_target", "Fill credentials only in a main-page input."
|
|
486
|
+
)
|
|
487
|
+
page_url = frame.url
|
|
488
|
+
origin = https_page_origin(page_url)
|
|
489
|
+
|
|
490
|
+
async def check_target() -> None:
|
|
491
|
+
target = await element.evaluate(
|
|
492
|
+
"""element => ({
|
|
493
|
+
tag: element.tagName.toLowerCase(),
|
|
494
|
+
type: element.type || '',
|
|
495
|
+
form_action: element.form ? element.form.action : ''
|
|
496
|
+
})"""
|
|
497
|
+
)
|
|
498
|
+
allowed = {"password"} if field_name == "password" else {"text", "email", "tel"}
|
|
499
|
+
if target["tag"] != "input" or target["type"] not in allowed:
|
|
500
|
+
raise LeashByteError(
|
|
501
|
+
422,
|
|
502
|
+
"invalid_secret_target",
|
|
503
|
+
"The credential field does not match the marked input.",
|
|
504
|
+
)
|
|
505
|
+
if target["form_action"] and https_page_origin(target["form_action"]) != origin:
|
|
506
|
+
raise LeashByteError(
|
|
507
|
+
422,
|
|
508
|
+
"invalid_secret_target",
|
|
509
|
+
"The credential form must submit to the current origin.",
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
await check_target()
|
|
513
|
+
try:
|
|
514
|
+
async with httpx.AsyncClient(trust_env=False, timeout=5) as client:
|
|
515
|
+
response = await client.post(
|
|
516
|
+
f"{self.settings.vault_base_url}/internal/resolve",
|
|
517
|
+
headers={"X-Vault-Resolve-Key": self.settings.require_vault_resolve_key()},
|
|
518
|
+
json={"alias": alias, "field": field_name, "origin": origin},
|
|
519
|
+
)
|
|
520
|
+
except httpx.HTTPError as error:
|
|
521
|
+
raise LeashByteError(502, "vault_unavailable", "The vault did not answer.") from error
|
|
522
|
+
if response.status_code >= 400:
|
|
523
|
+
try:
|
|
524
|
+
code = response.json()["error"]["code"]
|
|
525
|
+
except (ValueError, KeyError, TypeError):
|
|
526
|
+
code = "vault_request_failed"
|
|
527
|
+
if code == "origin_not_allowed":
|
|
528
|
+
raise LeashByteError(403, code, "This origin cannot use the credential.")
|
|
529
|
+
if code == "credential_not_found":
|
|
530
|
+
raise LeashByteError(404, code, "The credential was not found.")
|
|
531
|
+
raise LeashByteError(
|
|
532
|
+
502, "vault_request_failed", "The vault did not complete the fill."
|
|
533
|
+
)
|
|
534
|
+
secret = response.json().get("value")
|
|
535
|
+
if not isinstance(secret, str):
|
|
536
|
+
raise LeashByteError(
|
|
537
|
+
502, "vault_request_failed", "The vault returned an invalid value."
|
|
538
|
+
)
|
|
539
|
+
if session.cdp_issued:
|
|
540
|
+
raise LeashByteError(
|
|
541
|
+
409,
|
|
542
|
+
"cdp_fill_conflict",
|
|
543
|
+
"Use a different session for CDP or credential fills.",
|
|
544
|
+
)
|
|
545
|
+
if frame.url != page_url or https_page_origin(frame.url) != origin:
|
|
546
|
+
raise LeashByteError(
|
|
547
|
+
409, "origin_changed", "The page origin changed before the credential fill."
|
|
548
|
+
)
|
|
549
|
+
await check_target()
|
|
550
|
+
if secret not in session.redactions:
|
|
551
|
+
session.redactions.append(secret)
|
|
552
|
+
session.redactions = session.redactions[-20:]
|
|
553
|
+
await element.fill(secret)
|
|
554
|
+
secret = ""
|
|
555
|
+
|
|
556
|
+
async def screenshot(self, session: Session) -> bytes:
|
|
557
|
+
async with session.lock:
|
|
558
|
+
if session.redactions:
|
|
559
|
+
raise LeashByteError(
|
|
560
|
+
409,
|
|
561
|
+
"screenshot_redaction_active",
|
|
562
|
+
"Screenshots are disabled after a credential fill in this session.",
|
|
563
|
+
)
|
|
564
|
+
return await session.context.pages[-1].screenshot(type="png", full_page=False)
|
|
565
|
+
|
|
566
|
+
async def cdp_grant(self, session: Session, supplied_key: str | None) -> dict:
|
|
567
|
+
async with session.lock:
|
|
568
|
+
if session.redactions:
|
|
569
|
+
raise LeashByteError(
|
|
570
|
+
409,
|
|
571
|
+
"cdp_fill_conflict",
|
|
572
|
+
"Use a different session for CDP or credential fills.",
|
|
573
|
+
)
|
|
574
|
+
expected = self.settings.require_cdp_key().encode("utf-8")
|
|
575
|
+
if supplied_key is None or not hmac.compare_digest(
|
|
576
|
+
supplied_key.encode("utf-8"), expected
|
|
577
|
+
):
|
|
578
|
+
raise LeashByteError(
|
|
579
|
+
403, "cdp_capability_required", "Provide the separate CDP capability key."
|
|
580
|
+
)
|
|
581
|
+
token, expires = self.grants.issue(session.id)
|
|
582
|
+
session.cdp_issued = True
|
|
583
|
+
return {
|
|
584
|
+
"session_id": session.id,
|
|
585
|
+
"token": token,
|
|
586
|
+
"expires_in": expires,
|
|
587
|
+
"path": f"/v1/browser/sessions/{session.id}/cdp/connect",
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async def debugger_url(self, session: Session) -> str:
|
|
591
|
+
port_file = self.settings.browser_profile_root / session.profile / "DevToolsActivePort"
|
|
592
|
+
try:
|
|
593
|
+
port = int(port_file.read_text(encoding="ascii").splitlines()[0])
|
|
594
|
+
except (OSError, ValueError, IndexError) as error:
|
|
595
|
+
raise LeashByteError(
|
|
596
|
+
503, "cdp_unavailable", "Chrome debugging is not available."
|
|
597
|
+
) from error
|
|
598
|
+
try:
|
|
599
|
+
async with httpx.AsyncClient(trust_env=False, timeout=5) as client:
|
|
600
|
+
response = await client.get(f"http://127.0.0.1:{port}/json/list")
|
|
601
|
+
response.raise_for_status()
|
|
602
|
+
targets = response.json()
|
|
603
|
+
page_url = session.context.pages[-1].url
|
|
604
|
+
matches = [
|
|
605
|
+
item
|
|
606
|
+
for item in targets
|
|
607
|
+
if item.get("type") == "page" and item.get("url") == page_url
|
|
608
|
+
]
|
|
609
|
+
if len(matches) != 1:
|
|
610
|
+
raise ValueError
|
|
611
|
+
url = matches[0]["webSocketDebuggerUrl"]
|
|
612
|
+
if not urlsplit(url).path.startswith("/devtools/page/"):
|
|
613
|
+
raise ValueError
|
|
614
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
|
|
615
|
+
raise LeashByteError(
|
|
616
|
+
503, "cdp_unavailable", "Chrome page debugging is not available."
|
|
617
|
+
) from error
|
|
618
|
+
return url
|
|
619
|
+
|
|
620
|
+
async def open_cdp(self, session_id: str, token: str):
|
|
621
|
+
session = self.get(session_id)
|
|
622
|
+
if not self.grants.consume(session_id, token):
|
|
623
|
+
raise LeashByteError(
|
|
624
|
+
403, "invalid_cdp_grant", "The CDP grant is invalid, used, or expired."
|
|
625
|
+
)
|
|
626
|
+
url = await self.debugger_url(session)
|
|
627
|
+
return await websockets.connect(url, max_size=32 * 1024 * 1024)
|