Skip to content

Commit 0ea0dff

Browse files
NikhilShahiclaude
andcommitted
Add Web Bot Auth and proxy routes session options
- web_bot_auth (True, or {"only_domains": [...]}) signs the session's requests with Web Bot Auth. It works for sessions and agent tasks; SessionLaunchState.web_bot_auth reports how a session signs. - proxy_routes chooses, per host, whether traffic goes through the session's proxy ("default") or straight out ("direct"). It only applies when creating a session. - Both are in the request TypedDicts and the legacy Pydantic models. The new nested models reject unknown keys, so a misspelled "onlyDomains" fails instead of being dropped and signing for every host. - Runtime tests for both clients, mypy and Pyright fixtures, README sections, and the version goes to 1.13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 13396c4 commit 0ea0dff

9 files changed

Lines changed: 331 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,47 @@ Pydantic request/response models in `hyperbrowser.models`.
6868
See the [WebMCP guide](https://hyperbrowser.ai/docs/sessions/webmcp) for complete
6969
examples, cancellation behavior, human submission, and limits.
7070

71+
## Web Bot Auth
72+
73+
A session can sign its requests with Web Bot Auth so websites can verify that they
74+
come from Hyperbrowser. Web Bot Auth is enabled for teams on request.
75+
76+
```python
77+
session = client.sessions.create({"web_bot_auth": True})
78+
print(session.launch_state.web_bot_auth.signature_agent) # https://www.hyperbrowser.ai
79+
80+
# Sign only requests to these hosts and their subdomains:
81+
client.sessions.create({"web_bot_auth": {"only_domains": ["example.com"]}})
82+
```
83+
84+
Agent tasks accept `web_bot_auth` in `session_options`; scrape, crawl, and extract
85+
jobs do not. It cannot be combined with `use_ultra_stealth` or
86+
`start_from_snapshot`. See the
87+
[Web Bot Auth guide](https://hyperbrowser.ai/docs/sessions/web-bot-auth).
88+
89+
## Proxy routes
90+
91+
`proxy_routes` sends some hosts' traffic straight out instead of through the
92+
session's proxy, or the other way round. The first route that matches a host
93+
decides; hosts that no route matches use the proxy. Proxy routes require
94+
`use_proxy` and are enabled for teams on request.
95+
96+
```python
97+
session = client.sessions.create({
98+
"use_proxy": True,
99+
"proxy_routes": [
100+
{"match": "api.example.com", "proxy": "default"}, # stays on the proxy
101+
{"match": ["*.example.com", "cdn.example.net"], "proxy": "direct"},
102+
],
103+
})
104+
```
105+
106+
A pattern is a hostname, `*.example.com` (its subdomains, not `example.com`
107+
itself), or `*` (every host). Proxy routes only apply to `client.sessions.create`
108+
and cannot be combined with `allow_internet_access=False`, `allow_out`, or
109+
`deny_out`. See the
110+
[proxy routes guide](https://hyperbrowser.ai/docs/sessions/proxy-routes).
111+
71112
## Usage
72113

73114
Hyperbrowser 1.0 accepts plain dictionaries for request parameters. Method

‎hyperbrowser/models/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,9 @@
343343
SessionProfile,
344344
SessionLaunchState,
345345
StartSessionFromSnapshotParams,
346+
WebBotAuthParams,
347+
ProxyRoute,
348+
SessionWebBotAuth,
346349
UploadFileResponse,
347350
ImageCaptchaParam,
348351
CaptchaSolverType,
@@ -704,6 +707,9 @@
704707
"SessionProfile",
705708
"SessionLaunchState",
706709
"StartSessionFromSnapshotParams",
710+
"WebBotAuthParams",
711+
"ProxyRoute",
712+
"SessionWebBotAuth",
707713
"UploadFileResponse",
708714
"ImageCaptchaParam",
709715
"CaptchaSolverType",

‎hyperbrowser/models/session.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ class SessionProfile(BaseModel):
6262
)
6363

6464

65+
class SessionWebBotAuth(BaseModel):
66+
"""
67+
How a session signs its requests with Web Bot Auth.
68+
"""
69+
70+
model_config = ConfigDict(
71+
populate_by_alias=True,
72+
)
73+
74+
signature_agent: str = Field(alias="signatureAgent")
75+
"""The identity the session signs as, e.g. "https://www.hyperbrowser.ai"."""
76+
only_domains: Optional[List[str]] = Field(default=None, alias="onlyDomains")
77+
"""Present when signing is limited to these hostnames and their subdomains."""
78+
79+
6580
class UpdateSessionProfileParams(BaseModel):
6681
"""
6782
Parameters for updating session profile persistence settings.
@@ -130,6 +145,8 @@ class SessionLaunchState(BaseModel):
130145
)
131146

132147
enable_web_mcp: Optional[bool] = Field(default=None, alias="enableWebMcp")
148+
web_bot_auth: Optional[SessionWebBotAuth] = Field(default=None, alias="webBotAuth")
149+
"""Present only for a session that signs its requests with Web Bot Auth."""
133150

134151
use_ultra_stealth: Optional[bool] = Field(default=None, alias="useUltraStealth")
135152
use_stealth: Optional[bool] = Field(default=None, alias="useStealth")
@@ -404,6 +421,40 @@ class StartSessionFromSnapshotParams(BaseModel):
404421
snapshot_id: str = Field(serialization_alias="snapshotId")
405422

406423

424+
class WebBotAuthParams(BaseModel):
425+
"""
426+
Limits Web Bot Auth signing to some hosts.
427+
"""
428+
429+
# An unknown key (such as "onlyDomains") is an error rather than dropped:
430+
# dropping it would sign requests to every host.
431+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
432+
433+
only_domains: Optional[List[str]] = Field(
434+
default=None, serialization_alias="onlyDomains"
435+
)
436+
"""Sign only requests to these hostnames (e.g. "example.com") and their
437+
subdomains. No scheme, port or path; at most 50.
438+
"""
439+
440+
441+
class ProxyRoute(BaseModel):
442+
"""
443+
Sends traffic for matching hosts through the session's proxy or straight out.
444+
"""
445+
446+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
447+
448+
match: Union[str, List[str]]
449+
"""A hostname, "*.example.com" (subdomains, not example.com itself) or "*"
450+
(every host), or a list of them.
451+
"""
452+
proxy: Literal["direct", "default"]
453+
"""Where the traffic goes: "direct" sends it straight out of the browser's
454+
machine, and "default" sends it through the session's proxy.
455+
"""
456+
457+
407458
class CreateSessionParams(BaseModel):
408459
"""
409460
Parameters for creating a new browser session.
@@ -416,6 +467,24 @@ class CreateSessionParams(BaseModel):
416467
enable_web_mcp: Optional[bool] = Field(
417468
default=None, serialization_alias="enableWebMcp"
418469
)
470+
web_bot_auth: Optional[Union[bool, WebBotAuthParams]] = Field(
471+
default=None, serialization_alias="webBotAuth"
472+
)
473+
"""Sign the session's requests with Web Bot Auth so websites can verify they
474+
come from Hyperbrowser. Pass WebBotAuthParams(only_domains=[...]) to sign only
475+
for those hosts. Requires Web Bot Auth to be enabled for your team; works for
476+
sessions and agent tasks, and cannot be combined with use_ultra_stealth or
477+
start_from_snapshot.
478+
"""
479+
proxy_routes: Optional[List[ProxyRoute]] = Field(
480+
default=None, serialization_alias="proxyRoutes"
481+
)
482+
"""Choose, per host, whether the session's traffic goes through its proxy or
483+
straight out. The first route that matches a host decides; hosts that no
484+
route matches use the proxy. Requires use_proxy and proxy routes to be
485+
enabled for your team, only applies when creating a session, and cannot be
486+
combined with allow_internet_access=False, allow_out or deny_out.
487+
"""
419488

420489
use_ultra_stealth: bool = Field(
421490
default=False, serialization_alias="useUltraStealth"

‎hyperbrowser/types/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@
147147
SessionListParams,
148148
SessionStatus,
149149
StartSessionFromSnapshotParams,
150+
WebBotAuthParams,
151+
ProxyRoute,
150152
UpdateSessionProfileParams,
151153
UpdateSessionProxyLocationParams,
152154
UpdateSessionProxyParams,
@@ -323,6 +325,8 @@
323325
"SessionListParams",
324326
"SessionStatus",
325327
"StartSessionFromSnapshotParams",
328+
"WebBotAuthParams",
329+
"ProxyRoute",
326330
"UpdateSessionProfileParams",
327331
"UpdateSessionProxyLocationParams",
328332
"UpdateSessionProxyParams",

‎hyperbrowser/types/session.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import List, Literal, Optional
1+
from typing import List, Literal, Optional, Union
22

33
from typing_extensions import Required, TypedDict
44

@@ -114,10 +114,27 @@ class StartSessionFromSnapshotParams(TypedDict):
114114
snapshot_id: str
115115

116116

117+
class WebBotAuthParams(TypedDict, total=False):
118+
"""Hosts to limit Web Bot Auth signing to: hostnames such as "example.com",
119+
each also covering its subdomains."""
120+
121+
only_domains: List[str]
122+
123+
124+
class ProxyRoute(TypedDict):
125+
"""Sends traffic for matching hosts through the session's proxy ("default")
126+
or straight out ("direct"). A pattern is a hostname, "*.example.com" or "*"."""
127+
128+
match: Union[str, List[str]]
129+
proxy: Literal["direct", "default"]
130+
131+
117132
class CreateSessionParams(TypedDict, total=False):
118133
"""Configuration for creating a browser session."""
119134

120135
enable_web_mcp: Optional[bool]
136+
web_bot_auth: Optional[Union[bool, WebBotAuthParams]]
137+
proxy_routes: Optional[List[ProxyRoute]]
121138
use_ultra_stealth: bool
122139
use_stealth: bool
123140
use_proxy: bool

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "hyperbrowser"
3-
version = "1.12.0"
3+
version = "1.13.0"
44
description = "Python SDK for hyperbrowser"
55
authors = ["Nikhil Shahi <nshahi1998@gmail.com>"]
66
license = "MIT"
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import asyncio
2+
import json
3+
4+
import httpx
5+
import pytest
6+
from pydantic import ValidationError
7+
8+
from hyperbrowser import AsyncHyperbrowser, Hyperbrowser
9+
from hyperbrowser.exceptions import HyperbrowserError
10+
from hyperbrowser.models import (
11+
CreateSessionParams,
12+
ProxyRoute,
13+
SessionLaunchState,
14+
WebBotAuthParams,
15+
)
16+
17+
18+
@pytest.fixture(params=[False, True], ids=["sync", "async"])
19+
def sdk(request):
20+
"""Exercise the same wire contract through both public clients and transports."""
21+
is_async = request.param
22+
loop = asyncio.new_event_loop()
23+
client = (AsyncHyperbrowser if is_async else Hyperbrowser)(api_key="test-key")
24+
calls = []
25+
26+
def handler(req):
27+
calls.append(req)
28+
return httpx.Response(400, json={"message": "fixture stops before parsing"})
29+
30+
def call(method, *args, **kwargs):
31+
result = method(*args, **kwargs)
32+
return loop.run_until_complete(result) if is_async else result
33+
34+
call(client.transport.close)
35+
client.transport.client = (httpx.AsyncClient if is_async else httpx.Client)(
36+
transport=httpx.MockTransport(handler),
37+
headers={"x-api-key": "test-key"},
38+
timeout=30,
39+
)
40+
if is_async:
41+
client.transport._closed = False
42+
yield client, call, calls
43+
call(client.close)
44+
loop.close()
45+
46+
47+
def sent_bodies(sdk, method, params_list):
48+
client, call, calls = sdk
49+
for params in params_list:
50+
with pytest.raises(HyperbrowserError):
51+
call(method, params)
52+
return [json.loads(req.content) for req in calls]
53+
54+
55+
def test_web_bot_auth_on_the_wire(sdk):
56+
client = sdk[0]
57+
bodies = sent_bodies(
58+
sdk,
59+
client.sessions.create,
60+
[
61+
{},
62+
{"web_bot_auth": True},
63+
{"web_bot_auth": False},
64+
{"web_bot_auth": {"only_domains": ["example.com", "shop.example.org"]}},
65+
CreateSessionParams(web_bot_auth=True),
66+
CreateSessionParams(
67+
web_bot_auth=WebBotAuthParams(only_domains=["example.com"])
68+
),
69+
],
70+
)
71+
assert "webBotAuth" not in bodies[0]
72+
assert bodies[1]["webBotAuth"] is True
73+
assert bodies[2]["webBotAuth"] is False
74+
assert bodies[3]["webBotAuth"] == {
75+
"onlyDomains": ["example.com", "shop.example.org"]
76+
}
77+
assert bodies[4]["webBotAuth"] is True
78+
assert bodies[5]["webBotAuth"] == {"onlyDomains": ["example.com"]}
79+
80+
81+
def test_proxy_routes_on_the_wire_in_order(sdk):
82+
client = sdk[0]
83+
routes = [
84+
{"match": "api.example.com", "proxy": "default"},
85+
{"match": ["*.example.com", "cdn.example.net"], "proxy": "direct"},
86+
]
87+
bodies = sent_bodies(
88+
sdk,
89+
client.sessions.create,
90+
[
91+
{"use_proxy": True, "proxy_routes": routes},
92+
CreateSessionParams(
93+
use_proxy=True,
94+
proxy_routes=[ProxyRoute(**route) for route in routes],
95+
),
96+
],
97+
)
98+
for body in bodies:
99+
assert body["useProxy"] is True
100+
assert body["proxyRoutes"] == routes
101+
102+
103+
def test_agent_tasks_pass_web_bot_auth_through_session_options(sdk):
104+
client = sdk[0]
105+
bodies = sent_bodies(
106+
sdk,
107+
client.agents.browser_use.start,
108+
[
109+
{
110+
"task": "Open example.com",
111+
"session_options": {"web_bot_auth": {"only_domains": ["example.com"]}},
112+
}
113+
],
114+
)
115+
assert sdk[2][0].url.path == "/api/task/browser-use"
116+
assert bodies[0]["sessionOptions"]["webBotAuth"] == {"onlyDomains": ["example.com"]}
117+
118+
119+
def test_misspelled_or_invalid_options_fail_before_sending(sdk):
120+
client, call, calls = sdk
121+
for params in [
122+
# A dropped key would sign requests to every host instead of example.com.
123+
{"web_bot_auth": {"onlyDomains": ["example.com"]}},
124+
{"proxy_routes": [{"match": "example.com", "proxy": "upstream"}]},
125+
{"proxy_routes": [{"match": "example.com"}]},
126+
{"proxy_routes": [{"match": "example.com", "proxy": "direct", "port": 443}]},
127+
]:
128+
with pytest.raises(ValidationError):
129+
call(client.sessions.create, params)
130+
assert calls == []
131+
132+
133+
def test_launch_state_reports_how_a_session_signs():
134+
state = SessionLaunchState.model_validate(
135+
{
136+
"webBotAuth": {
137+
"signatureAgent": "https://www.hyperbrowser.ai",
138+
"onlyDomains": ["example.com"],
139+
}
140+
}
141+
)
142+
assert state.web_bot_auth is not None
143+
assert state.web_bot_auth.signature_agent == "https://www.hyperbrowser.ai"
144+
assert state.web_bot_auth.only_domains == ["example.com"]
145+
unscoped = SessionLaunchState.model_validate(
146+
{"webBotAuth": {"signatureAgent": "https://www.hyperbrowser.ai"}}
147+
)
148+
assert unscoped.web_bot_auth is not None
149+
assert unscoped.web_bot_auth.only_domains is None
150+
assert SessionLaunchState().web_bot_auth is None

‎tests/typecheck/invalid_requests.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,10 @@ def invalid_webmcp_requests(client: Hyperbrowser) -> None:
102102
async def invalid_async_webmcp_requests(client: AsyncHyperbrowser) -> None:
103103
await client.sessions.webmcp.invoke("s", {"tool_ref": "t", "timeout_seconds": "60"}) # M,P
104104
await client.sessions.webmcp.get_result("s", "i", {"waitSeconds": 30}) # M,P
105+
106+
107+
def invalid_web_bot_auth_and_proxy_routes_requests(client: Hyperbrowser) -> None:
108+
client.sessions.create({"web_bot_auth": {"onlyDomains": ["a.com"]}}) # M,P
109+
client.sessions.create({"web_bot_auth": "yes"}) # M,P
110+
client.sessions.create({"proxy_routes": [{"match": "a.com"}]}) # M,P
111+
client.sessions.create({"proxy_routes": [{"match": "*", "proxy": "up"}]}) # M,P

0 commit comments

Comments
 (0)