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/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)