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.
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/PKG-INFO +1 -1
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/pyproject.toml +1 -1
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/service-worker.js +34 -4
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/protocol_version.py +1 -1
- stagehand-4.3.0a0.dev1624/src/stagehand/_validation.py +139 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browser.py +23 -1
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/cdp_client.py +98 -10
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/client_models.py +7 -2
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/client_types.py +4 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/stagehand.py +4 -1
- stagehand-4.2.2a0.dev1615/src/stagehand/_validation.py +0 -67
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/README.md +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/__init__.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/blank.html +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/content-script.js +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/manifest.json +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/offscreen/service-worker-heartbeat.html +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/offscreen/service-worker-heartbeat.js +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/wake-service-worker.html +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/wake-service-worker.js +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/__init__.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/input_types.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/models.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_sdk_identity.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browser_clipboard.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browser_context.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_connection.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_rpc.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_services.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_session.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_transport.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/extension_assets.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/file_upload.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/locator.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/page.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/py.typed +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/response.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/rpc_client.py +0 -0
- {stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/timeouts.py +0 -0
- {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.
|
|
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
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/service-worker.js
RENAMED
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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
|
});
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/protocol_version.py
RENAMED
|
@@ -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-
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/content-script.js
RENAMED
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_extension/manifest.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/__init__.py
RENAMED
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/_generated/input_types.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_connection.py
RENAMED
|
File without changes
|
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_services.py
RENAMED
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_session.py
RENAMED
|
File without changes
|
{stagehand-4.2.2a0.dev1615 → stagehand-4.3.0a0.dev1624}/src/stagehand/browserbase_transport.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|