stagehand 4.2.2a0.dev1615__tar.gz → 4.3.0a0.dev1624__tar.gz

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 (40) hide show
  1. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/PKG-INFO +1 -1
  2. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/pyproject.toml +1 -1
  3. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/service-worker.js +34 -4
  4. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/protocol_version.py +1 -1
  5. stagehand-4.3.0a0.dev1624/src/stagehand/_validation.py +139 -0
  6. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browser.py +23 -1
  7. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/cdp_client.py +98 -10
  8. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/client_models.py +7 -2
  9. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/client_types.py +4 -0
  10. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/stagehand.py +4 -1
  11. stagehand-4.2.2a0.dev1615/src/stagehand/_validation.py +0 -67
  12. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/README.md +0 -0
  13. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/__init__.py +0 -0
  14. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/blank.html +0 -0
  15. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/content-script.js +0 -0
  16. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/manifest.json +0 -0
  17. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/offscreen/service-worker-heartbeat.html +0 -0
  18. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/offscreen/service-worker-heartbeat.js +0 -0
  19. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/wake-service-worker.html +0 -0
  20. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/wake-service-worker.js +0 -0
  21. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/__init__.py +0 -0
  22. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/input_types.py +0 -0
  23. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/models.py +0 -0
  24. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_sdk_identity.py +0 -0
  25. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browser_clipboard.py +0 -0
  26. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browser_context.py +0 -0
  27. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_connection.py +0 -0
  28. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_rpc.py +0 -0
  29. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_services.py +0 -0
  30. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_session.py +0 -0
  31. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_transport.py +0 -0
  32. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/extension_assets.py +0 -0
  33. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/file_upload.py +0 -0
  34. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/locator.py +0 -0
  35. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/page.py +0 -0
  36. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/py.typed +0 -0
  37. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/response.py +0 -0
  38. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/rpc_client.py +0 -0
  39. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/timeouts.py +0 -0
  40. {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/webmcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stagehand
3
- Version: 4.2.2a0.dev1615
3
+ Version: 4.3.0a0.dev1624
4
4
  Summary: Browserbase's SDK for building browser agents
5
5
  Keywords: ai,agent,ai-agent,browser,browser-agent,browser-automation,computer-use,headless,playwright,cdp,llm,automation,web-scraping,testing
6
6
  Author: Browserbase
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stagehand"
3
- version = "4.2.2a0.dev1615"
3
+ version = "4.3.0a0.dev1624"
4
4
  description = "Browserbase's SDK for building browser agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -7363,7 +7363,7 @@ function isRecord$1(value) {
7363
7363
  }
7364
7364
  var package_default$1 = {
7365
7365
  name: "@browserbasehq/stagehand-protocol",
7366
- version: "3.0.0-alpha-c6b6f278c880d2f8b770ac0c1ecc63f248515968",
7366
+ version: "3.0.0-alpha-77170ed9f8252581458360ab511c55a4be31fb44",
7367
7367
  "private": true,
7368
7368
  type: "module",
7369
7369
  exports: {
@@ -12527,9 +12527,36 @@ strictObject({
12527
12527
  acceptDownloads: boolean().optional(),
12528
12528
  keepAlive: boolean().optional()
12529
12529
  }).meta({ id: "LocalBrowserLaunchOptions" });
12530
+ var CDPHeadersSchema = preprocess((value) => {
12531
+ try {
12532
+ if (typeof value !== "object" || value === null || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return null;
12533
+ const entries = Object.entries(value);
12534
+ if (!entries.every(([name, content]) => name.length > 0 && !/[^!#$%&'*+\-.^_`|~0-9A-Za-z]/.test(name) && typeof content === "string" && !/[^\t\x20-\x7e]/.test(content) && content === content.replace(/^[ \t]+|[ \t]+$/g, ""))) return null;
12535
+ return Object.fromEntries(entries);
12536
+ } catch {
12537
+ return null;
12538
+ }
12539
+ }, record(string(), string(), { error: "headers must map valid HTTP header names to ASCII string values without leading or trailing spaces or tabs" }));
12530
12540
  strictObject({
12531
12541
  cdpUrl: string().min(1),
12532
- extensionId: string().optional()
12542
+ /** Headers for client-side CDP discovery and WebSocket connections. */
12543
+ headers: CDPHeadersSchema.optional(),
12544
+ extensionId: string().optional(),
12545
+ /**
12546
+ * Browser-level CDP WebSocket URL dialed by the extension inside Chrome.
12547
+ * Defaults to the client's URL. Set this when the client connects through
12548
+ * a gateway the extension cannot or should not use.
12549
+ */
12550
+ extensionCdpUrl: url({
12551
+ protocol: /^wss?$/,
12552
+ hostname: /.+/
12553
+ }).refine((value) => {
12554
+ try {
12555
+ return new URL(value).port !== "0";
12556
+ } catch {
12557
+ return false;
12558
+ }
12559
+ }, { message: "Port must be between 1 and 65535" }).optional()
12533
12560
  }).meta({ id: "LocalBrowserConnectOptions" });
12534
12561
  var DEFAULT_BROWSERBASE_URL = "https://api.browserbase.com";
12535
12562
  var BrowserbaseClientOptionsSchema = strictObject({
@@ -54456,7 +54483,7 @@ var WebTracerProvider = class extends BasicTracerProvider {
54456
54483
  };
54457
54484
  var package_default = {
54458
54485
  name: "@browserbasehq/stagehand-extension",
54459
- version: "1.2.0-alpha-c6b6f278c880d2f8b770ac0c1ecc63f248515968",
54486
+ version: "1.2.0-alpha-77170ed9f8252581458360ab511c55a4be31fb44",
54460
54487
  "private": true,
54461
54488
  type: "module",
54462
54489
  scripts: {
@@ -58376,12 +58403,14 @@ var StagehandRuntime = class {
58376
58403
  const { cdpUrl } = params;
58377
58404
  const previousSession = this.browserSession;
58378
58405
  this.browserSession = void 0;
58406
+ this.browserSessionCdpUrl = void 0;
58379
58407
  this.disposeAllPageEventSubscriptions();
58380
58408
  this.pagesById.clear();
58381
58409
  this.responseHandles.clear();
58382
58410
  await previousSession?.close();
58383
58411
  try {
58384
58412
  this.browserSession = await this.adapters.browserSessionFactory(cdpUrl, this.logger, bootstrapLogger);
58413
+ this.browserSessionCdpUrl = cdpUrl;
58385
58414
  } catch (error) {
58386
58415
  await this.browserSession?.close();
58387
58416
  this.browserSession = void 0;
@@ -58396,7 +58425,7 @@ var StagehandRuntime = class {
58396
58425
  const state = this.state.getState();
58397
58426
  if (state.status !== "idle") throw new Error("A Stagehand instance is already initialized");
58398
58427
  this.logger.setLevel(params.logLevel);
58399
- if (!this.browserSession?.connected) {
58428
+ if (!this.browserSession?.connected || params.browserCdpUrl !== void 0 && params.browserCdpUrl !== this.browserSessionCdpUrl) {
58400
58429
  if (!params.browserCdpUrl) throw new Error("stagehand.init requires browserCdpUrl until resident mode is active");
58401
58430
  await this.replaceBrowserConnection({ cdpUrl: params.browserCdpUrl }, logger);
58402
58431
  }
@@ -58798,6 +58827,7 @@ var StagehandRuntime = class {
58798
58827
  await this.enqueueLifecycle(async () => {
58799
58828
  const session = this.browserSession;
58800
58829
  this.browserSession = void 0;
58830
+ this.browserSessionCdpUrl = void 0;
58801
58831
  this.clearStagehandInstance();
58802
58832
  await session?.close();
58803
58833
  });
@@ -1,4 +1,4 @@
1
1
  """Generated from packages/protocol/package.json. Do not edit."""
2
2
 
3
3
  PROTOCOL_SEMVER_PATTERN = '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$'
4
- STAGEHAND_PROTOCOL_VERSION = '3.0.0-alpha-c6b6f278c880d2f8b770ac0c1ecc63f248515968'
4
+ STAGEHAND_PROTOCOL_VERSION = '3.0.0-alpha-77170ed9f8252581458360ab511c55a4be31fb44'
@@ -0,0 +1,139 @@
1
+ import re
2
+ from collections.abc import Mapping
3
+ from typing import Annotated, cast
4
+ from urllib.parse import urlparse
5
+
6
+ from pydantic import (
7
+ AfterValidator,
8
+ AnyUrl,
9
+ BaseModel,
10
+ ConfigDict,
11
+ TypeAdapter,
12
+ ValidationError,
13
+ ValidatorFunctionWrapHandler,
14
+ WrapValidator,
15
+ model_validator,
16
+ )
17
+ from typing_extensions import override
18
+
19
+ _url_adapter = TypeAdapter(AnyUrl)
20
+
21
+
22
+ def _validate_url(value: str) -> str:
23
+ _url_adapter.validate_python(value)
24
+ return value
25
+
26
+
27
+ WireUrl = Annotated[str, AfterValidator(_validate_url)]
28
+
29
+ # RFC 9110 field names are tokens. Restrict values to ASCII so HTTP discovery
30
+ # and WebSocket handshakes encode them identically; allow only internal whitespace.
31
+ _HEADER_NAME = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
32
+ _HEADER_VALUE = re.compile(r"[\t\x20-\x7e]*")
33
+
34
+
35
+ def _validate_headers(value: Mapping[str, str]) -> Mapping[str, str]:
36
+ for name, header_value in value.items():
37
+ if not _HEADER_NAME.fullmatch(name):
38
+ raise ValueError
39
+ if not _HEADER_VALUE.fullmatch(header_value) or header_value != header_value.strip(" \t"):
40
+ raise ValueError
41
+ return value
42
+
43
+
44
+ def _redact_header_validation_errors(
45
+ value: object, handler: ValidatorFunctionWrapHandler
46
+ ) -> dict[str, str]:
47
+ try:
48
+ return cast(dict[str, str], handler(value))
49
+ except ValidationError:
50
+ # Error inputs, locations, and validator context can all contain credentials.
51
+ # Rebuild the error rather than only hiding inputs in its formatted message.
52
+ raise ValidationError.from_exception_data(
53
+ "CDP headers",
54
+ [
55
+ {
56
+ "type": "value_error",
57
+ "loc": (),
58
+ "input": "[redacted]",
59
+ "ctx": {
60
+ "error": ValueError(
61
+ "headers must map valid HTTP header names to ASCII string values "
62
+ "without leading or trailing spaces or tabs"
63
+ )
64
+ },
65
+ }
66
+ ],
67
+ hide_input=True,
68
+ ) from None
69
+
70
+
71
+ WireHeaders = Annotated[
72
+ dict[str, str],
73
+ AfterValidator(_validate_headers),
74
+ WrapValidator(_redact_header_validation_errors),
75
+ ]
76
+
77
+
78
+ def _validate_websocket_url(value: str) -> str:
79
+ parsed = urlparse(value)
80
+ if parsed.scheme not in ("ws", "wss") or not parsed.hostname:
81
+ raise ValueError("must be a ws:// or wss:// URL with a host")
82
+ _url_adapter.validate_python(value)
83
+ if parsed.port == 0:
84
+ raise ValueError("port must be between 1 and 65535")
85
+ return value
86
+
87
+
88
+ WireWebSocketUrl = Annotated[str, AfterValidator(_validate_websocket_url)]
89
+
90
+
91
+ class WireModel(BaseModel):
92
+ model_config = ConfigDict(validate_assignment=True, allow_inf_nan=False)
93
+
94
+ @override
95
+ def model_post_init(self, context: object, /) -> None:
96
+ super().model_post_init(context)
97
+ # Protocol defaults must survive `model_dump(exclude_unset=True)` at the wire boundary.
98
+ for name, field in type(self).model_fields.items():
99
+ value = getattr(self, name)
100
+ if name not in self.model_fields_set and not field.is_required() and value is not None:
101
+ self.model_fields_set.add(name)
102
+
103
+
104
+ class PageScreenshotOptionsValidation(WireModel):
105
+ @model_validator(mode="before")
106
+ @classmethod
107
+ def validate_cross_field_options(cls, value: object) -> object:
108
+ if not isinstance(value, Mapping):
109
+ return value
110
+ if value.get("full_page") and value.get("clip") is not None:
111
+ raise ValueError("fullPage and clip cannot be used together")
112
+ if "quality" in value and value.get("type") != "jpeg":
113
+ raise ValueError('quality is only valid when type is "jpeg"')
114
+ return value
115
+
116
+
117
+ class TelemetryConfigValidation(WireModel):
118
+ @model_validator(mode="before")
119
+ @classmethod
120
+ def validate_trace_endpoint(cls, value: object) -> object:
121
+ if not isinstance(value, Mapping):
122
+ return value
123
+ traces = value.get("traces")
124
+ if not isinstance(traces, Mapping):
125
+ return value
126
+ endpoint = traces.get("endpoint")
127
+ if isinstance(endpoint, str) and not urlparse(endpoint).path.endswith("/v1/traces"):
128
+ raise ValueError("OTLP trace endpoint must end with /v1/traces")
129
+ return value
130
+
131
+
132
+ class StagehandInitParamsValidation(WireModel):
133
+ @model_validator(mode="before")
134
+ @classmethod
135
+ def validate_browserbase_metadata(cls, value: object) -> object:
136
+ if isinstance(value, Mapping):
137
+ if value.get("browserbase") is not None and value.get("browser") is None:
138
+ raise ValueError("Client Browserbase transport requires browser metadata")
139
+ return value
@@ -85,6 +85,8 @@ _CHROME_REQUEST_TIMEOUT_SECONDS = 0.1
85
85
  class _WorkerInitMetadata:
86
86
  api_key: str | None
87
87
  browser: BrowserSessionMetadata | None
88
+ browser_cdp_url: str | None = None
89
+ """The CDP WebSocket the extension dials, when it is not the client's own."""
88
90
 
89
91
 
90
92
  @dataclass(frozen=True)
@@ -310,12 +312,14 @@ async def _connect_browser(
310
312
  preloaded_extension: bool = False,
311
313
  after_connect: Callable[[CDPClient], Awaitable[None]] | None = None,
312
314
  worker_init_metadata: _WorkerInitMetadata,
315
+ headers: Mapping[str, str] | None = None,
313
316
  ) -> StagehandBrowser:
314
317
  owns_source = origin == "launched" and not source.keep_alive
315
318
  cdp_client: CDPClient | None = None
316
319
  try:
317
320
  cdp_client = await CDPClient.connect(
318
321
  cdp_url=source.cdp_url,
322
+ **({"headers": headers} if headers else {}),
319
323
  extension_dir=extension_dir,
320
324
  local_extension_dir=local_extension_dir,
321
325
  extension_id=extension_id,
@@ -533,18 +537,31 @@ class LocalBrowser:
533
537
  *,
534
538
  cdp_url: str,
535
539
  extension_id: str | None = None,
540
+ extension_cdp_url: str | None = None,
541
+ headers: Mapping[str, str] | None = None,
536
542
  ) -> StagehandBrowser:
537
543
  """Connect to an existing browser.
538
544
 
539
545
  Args:
540
546
  extension_id: Deprecated and ignored. Omit this argument; Stagehand
541
547
  discovers the installed extension automatically.
548
+ extension_cdp_url: The browser-level CDP WebSocket that Stagehand's
549
+ extension dials from inside the browser. Defaults to the one this
550
+ client connected to. Set it when the client reaches Chrome through
551
+ a proxy the browser cannot or should not use, such as an
552
+ authenticated gateway whose URL carries credentials.
553
+ headers: HTTP headers to send with the CDP connection, such as a
554
+ gateway's credentials, so they need not ride in `cdp_url`. They
555
+ go to `cdp_url` and to the WebSocket it advertises when that is
556
+ on the same host, never elsewhere, and never to the extension.
542
557
  """
543
558
  options = LocalBrowserConnectOptions.model_validate({
544
559
  name: value
545
560
  for name, value in (
546
561
  ("cdp_url", cdp_url),
547
562
  ("extension_id", extension_id),
563
+ ("extension_cdp_url", extension_cdp_url),
564
+ ("headers", dict(headers) if isinstance(headers, Mapping) else headers),
548
565
  )
549
566
  if value is not None
550
567
  })
@@ -553,7 +570,12 @@ class LocalBrowser:
553
570
  origin="connected",
554
571
  source=_ConnectedBrowserSource(options.cdp_url),
555
572
  local_extension_dir=lambda: str(extension_directory()),
556
- worker_init_metadata=_WorkerInitMetadata(api_key=None, browser=None),
573
+ worker_init_metadata=_WorkerInitMetadata(
574
+ api_key=None,
575
+ browser=None,
576
+ browser_cdp_url=options.extension_cdp_url,
577
+ ),
578
+ headers=options.headers,
557
579
  )
558
580
 
559
581
 
@@ -2,19 +2,28 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import logging
5
6
  import re
6
7
  import time
7
8
  from collections.abc import Callable, Mapping
8
9
  from contextlib import suppress
9
10
  from dataclasses import dataclass
10
11
  from typing import Any, Literal, Protocol, cast
11
- from urllib.request import urlopen
12
+ from urllib.error import HTTPError
13
+ from urllib.parse import urljoin, urlsplit
14
+ from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
15
+
16
+ from typing_extensions import override
17
+ from websockets.asyncio.client import connect as WebSocketConnect
18
+ from websockets.exceptions import InvalidStatus
12
19
 
13
20
  from stagehand._generated.protocol_version import (
14
21
  PROTOCOL_SEMVER_PATTERN,
15
22
  STAGEHAND_PROTOCOL_VERSION,
16
23
  )
17
24
 
25
+ _LOGGER = logging.getLogger(__name__)
26
+
18
27
  STAGEHAND_SEND_TO_HOST_BINDING = "__stagehandSendToHost"
19
28
  _RUNTIME_NAME = "stagehand"
20
29
  _STAGEHAND_EXTENSION_NAME = "Stagehand Runtime"
@@ -257,6 +266,7 @@ class CDPClient:
257
266
  extension_id: str | None = None,
258
267
  preloaded_extension: bool = False,
259
268
  service_worker_url_includes: str | None = None,
269
+ headers: Mapping[str, str] | None = None,
260
270
  ) -> CDPClient:
261
271
  """Connect without an independent lifecycle deadline.
262
272
 
@@ -278,8 +288,16 @@ class CDPClient:
278
288
  "or preloaded_extension is required"
279
289
  )
280
290
 
281
- web_socket_debugger_url = await _resolve_browser_web_socket_url(cdp_url)
282
- socket = await _connect_web_socket(web_socket_debugger_url)
291
+ web_socket_debugger_url = await _resolve_browser_web_socket_url(cdp_url, headers=headers)
292
+ web_socket_headers = headers or None
293
+ if web_socket_headers and not _can_forward_headers(cdp_url, web_socket_debugger_url):
294
+ web_socket_headers = None
295
+ _LOGGER.debug(
296
+ "CDP headers were withheld from the advertised WebSocket endpoint because "
297
+ "its host or port differs, or forwarding would downgrade transport security. "
298
+ "If the handshake is rejected, check the gateway's advertised CDP endpoint."
299
+ )
300
+ socket = await _connect_web_socket(web_socket_debugger_url, headers=web_socket_headers)
283
301
  client = cls(socket, web_socket_debugger_url)
284
302
 
285
303
  try:
@@ -659,33 +677,103 @@ class CDPClient:
659
677
  task.add_done_callback(finish)
660
678
 
661
679
 
662
- async def _connect_web_socket(url: str) -> _WebSocket:
663
- from websockets.asyncio.client import connect
680
+ class _UnsafeCDPRedirectError(RuntimeError):
681
+ """A redirect would expose caller headers to an unauthorized endpoint."""
682
+
683
+
684
+ _DEFAULT_PORTS = {"http": 80, "ws": 80, "https": 443, "wss": 443}
685
+
686
+
687
+ def _can_forward_headers(source: str, destination: str) -> bool:
688
+ """Require the same host and effective port, without a TLS downgrade."""
689
+ try:
690
+ first, second = urlsplit(source), urlsplit(destination)
691
+ return (
692
+ first.scheme in _DEFAULT_PORTS
693
+ and second.scheme in _DEFAULT_PORTS
694
+ and first.hostname is not None
695
+ and first.hostname == second.hostname
696
+ and (first.port if first.port is not None else _DEFAULT_PORTS[first.scheme])
697
+ == (second.port if second.port is not None else _DEFAULT_PORTS[second.scheme])
698
+ and not (first.scheme in ("https", "wss") and second.scheme in ("http", "ws"))
699
+ )
700
+ except ValueError:
701
+ return False
702
+
703
+
704
+ class _CDPRedirectHandler(HTTPRedirectHandler):
705
+ @override
706
+ def redirect_request(
707
+ self, req: Request, fp: Any, code: int, msg: str, headers: Any, newurl: str
708
+ ) -> Request | None:
709
+ if not _can_forward_headers(req.full_url, newurl):
710
+ raise _UnsafeCDPRedirectError(
711
+ "Refusing CDP HTTP redirect with headers to a different host or port, "
712
+ "or an insecure endpoint"
713
+ )
714
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
715
+
716
+
717
+ class _CDPWebSocketConnect(WebSocketConnect):
718
+ @override
719
+ def process_redirect(self, exc: Exception) -> Exception | str:
720
+ if (
721
+ self.additional_headers
722
+ and isinstance(exc, InvalidStatus)
723
+ and exc.response.status_code in (300, 301, 302, 303, 307, 308)
724
+ and "Location" in exc.response.headers
725
+ and not _can_forward_headers(
726
+ self.uri, urljoin(self.uri, exc.response.headers["Location"])
727
+ )
728
+ ):
729
+ return _UnsafeCDPRedirectError(
730
+ "Refusing CDP WebSocket redirect with headers to a different host or port, "
731
+ "or an insecure endpoint"
732
+ )
733
+ return super().process_redirect(exc)
734
+
664
735
 
736
+ async def _connect_web_socket(url: str, *, headers: Mapping[str, str] | None = None) -> _WebSocket:
665
737
  return cast(
666
738
  _WebSocket,
667
- await connect(url, open_timeout=None, max_size=None),
739
+ await _CDPWebSocketConnect(
740
+ url, open_timeout=None, max_size=None, additional_headers=headers
741
+ ),
668
742
  )
669
743
 
670
744
 
671
- async def _resolve_browser_web_socket_url(cdp_url: str) -> str:
745
+ async def _resolve_browser_web_socket_url(
746
+ cdp_url: str, *, headers: Mapping[str, str] | None = None
747
+ ) -> str:
672
748
  if cdp_url.startswith(("ws://", "wss://")):
673
749
  return cdp_url
674
750
 
675
751
  base_url = cdp_url.rstrip("/")
676
752
  while True:
677
753
  try:
678
- version = await asyncio.to_thread(_read_json, f"{base_url}/json/version")
754
+ version = await asyncio.to_thread(_read_json, f"{base_url}/json/version", headers)
679
755
  debugger_url = version.get("webSocketDebuggerUrl")
680
756
  if isinstance(debugger_url, str) and debugger_url:
681
757
  return debugger_url
758
+ except _UnsafeCDPRedirectError:
759
+ raise
760
+ except HTTPError as error:
761
+ error.close()
762
+ if error.code in (401, 403):
763
+ raise RuntimeError(
764
+ f"CDP discovery was rejected with HTTP {error.code}. "
765
+ "Check the gateway credentials and access permissions."
766
+ ) from None
682
767
  except Exception:
683
768
  pass
684
769
  await asyncio.sleep(0.25)
685
770
 
686
771
 
687
- def _read_json(url: str) -> dict[str, object]:
688
- with urlopen(url, timeout=2) as response: # noqa: S310 -- The user selects the CDP URL.
772
+ def _read_json(url: str, headers: Mapping[str, str] | None = None) -> dict[str, object]:
773
+ request = Request(url, headers=dict(headers or {}))
774
+ # Keep the existing redirect behavior for connections without caller headers.
775
+ open_url = build_opener(_CDPRedirectHandler()).open if headers else urlopen
776
+ with open_url(request, timeout=2) as response:
689
777
  value: Any = json.load(response)
690
778
  if not isinstance(value, dict):
691
779
  raise RuntimeError("CDP version endpoint returned invalid JSON")
@@ -18,7 +18,7 @@ from ._generated.models import (
18
18
  StagehandResultMetadata,
19
19
  TelemetryConfig,
20
20
  )
21
- from ._validation import WireModel, WireUrl
21
+ from ._validation import WireHeaders, WireModel, WireUrl, WireWebSocketUrl
22
22
  from .browserbase_session import DEFAULT_BROWSERBASE_URL
23
23
  from .client_types import Cache as CacheInput
24
24
 
@@ -103,11 +103,16 @@ class LocalBrowserLaunchOptions(WireModel):
103
103
 
104
104
 
105
105
  class LocalBrowserConnectOptions(WireModel):
106
- model_config = ConfigDict(extra="forbid", strict=True)
106
+ # Headers may carry credentials, so validation errors never echo the input.
107
+ model_config = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True)
107
108
 
108
109
  cdp_url: Annotated[str, Field(min_length=1)]
109
110
  extension_id: str | None = None
110
111
  """Deprecated and ignored. Omit this field; Stagehand discovers the extension automatically."""
112
+ extension_cdp_url: WireWebSocketUrl | None = None
113
+ """Browser-level CDP WebSocket the extension dials, when it differs from `cdp_url`."""
114
+ headers: WireHeaders | None = None
115
+ """HTTP headers sent with the CDP connection, e.g. a gateway's credentials."""
111
116
 
112
117
 
113
118
  class BrowserbaseClientOptions(WireModel):
@@ -67,6 +67,10 @@ class LocalBrowserConnectOptions(TypedDict):
67
67
  cdp_url: str
68
68
  extension_id: NotRequired[str]
69
69
  """Deprecated and ignored. Omit this field; Stagehand discovers the extension automatically."""
70
+ extension_cdp_url: NotRequired[str]
71
+ """Browser-level CDP WebSocket the extension dials, when it differs from `cdp_url`."""
72
+ headers: NotRequired[dict[str, str]]
73
+ """HTTP headers sent with the CDP connection, e.g. a gateway's credentials."""
70
74
 
71
75
 
72
76
  class BrowserbaseClientOptions(TypedDict, total=False):
@@ -503,7 +503,10 @@ class Stagehand:
503
503
  def _worker_init_params(
504
504
  self, claimed: _ClaimedBrowser, *, has_browserbase_connection: bool
505
505
  ) -> StagehandInitParams:
506
- browser_cdp_url = claimed.cdp_client.web_socket_debugger_url
506
+ browser_cdp_url = (
507
+ claimed.worker_init_metadata.browser_cdp_url
508
+ or claimed.cdp_client.web_socket_debugger_url
509
+ )
507
510
  if browser_cdp_url is None:
508
511
  raise RuntimeError("The browser CDP WebSocket URL is unavailable")
509
512
  values = self._create_config.model_dump(
@@ -1,67 +0,0 @@
1
- from collections.abc import Mapping
2
- from typing import Annotated
3
- from urllib.parse import urlparse
4
-
5
- from pydantic import AfterValidator, AnyUrl, BaseModel, ConfigDict, TypeAdapter, model_validator
6
- from typing_extensions import override
7
-
8
- _url_adapter = TypeAdapter(AnyUrl)
9
-
10
-
11
- def _validate_url(value: str) -> str:
12
- _url_adapter.validate_python(value)
13
- return value
14
-
15
-
16
- WireUrl = Annotated[str, AfterValidator(_validate_url)]
17
-
18
-
19
- class WireModel(BaseModel):
20
- model_config = ConfigDict(validate_assignment=True, allow_inf_nan=False)
21
-
22
- @override
23
- def model_post_init(self, context: object, /) -> None:
24
- super().model_post_init(context)
25
- # Protocol defaults must survive `model_dump(exclude_unset=True)` at the wire boundary.
26
- for name, field in type(self).model_fields.items():
27
- value = getattr(self, name)
28
- if name not in self.model_fields_set and not field.is_required() and value is not None:
29
- self.model_fields_set.add(name)
30
-
31
-
32
- class PageScreenshotOptionsValidation(WireModel):
33
- @model_validator(mode="before")
34
- @classmethod
35
- def validate_cross_field_options(cls, value: object) -> object:
36
- if not isinstance(value, Mapping):
37
- return value
38
- if value.get("full_page") and value.get("clip") is not None:
39
- raise ValueError("fullPage and clip cannot be used together")
40
- if "quality" in value and value.get("type") != "jpeg":
41
- raise ValueError('quality is only valid when type is "jpeg"')
42
- return value
43
-
44
-
45
- class TelemetryConfigValidation(WireModel):
46
- @model_validator(mode="before")
47
- @classmethod
48
- def validate_trace_endpoint(cls, value: object) -> object:
49
- if not isinstance(value, Mapping):
50
- return value
51
- traces = value.get("traces")
52
- if not isinstance(traces, Mapping):
53
- return value
54
- endpoint = traces.get("endpoint")
55
- if isinstance(endpoint, str) and not urlparse(endpoint).path.endswith("/v1/traces"):
56
- raise ValueError("OTLP trace endpoint must end with /v1/traces")
57
- return value
58
-
59
-
60
- class StagehandInitParamsValidation(WireModel):
61
- @model_validator(mode="before")
62
- @classmethod
63
- def validate_browserbase_metadata(cls, value: object) -> object:
64
- if isinstance(value, Mapping):
65
- if value.get("browserbase") is not None and value.get("browser") is None:
66
- raise ValueError("Client Browserbase transport requires browser metadata")
67
- return value