Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,47 @@ Pydantic request/response models in `hyperbrowser.models`.
See the [WebMCP guide](https://hyperbrowser.ai/docs/sessions/webmcp) for complete
examples, cancellation behavior, human submission, and limits.

## Web Bot Auth

A session can sign its requests with Web Bot Auth so websites can verify that they
come from Hyperbrowser. Web Bot Auth is enabled for teams on request.

```python
session = client.sessions.create({"web_bot_auth": True})
print(session.launch_state.web_bot_auth.signature_agent) # https://www.hyperbrowser.ai

# Sign only requests to these hosts and their subdomains:
client.sessions.create({"web_bot_auth": {"only_domains": ["example.com"]}})
```

Agent tasks accept `web_bot_auth` in `session_options`; scrape, crawl, and extract
jobs do not. It cannot be combined with `use_ultra_stealth` or
`start_from_snapshot`. See the
[Web Bot Auth guide](https://hyperbrowser.ai/docs/sessions/web-bot-auth).

## Proxy routes

`proxy_routes` sends some hosts' traffic straight out instead of through the
session's proxy, or the other way round. The first route that matches a host
decides; hosts that no route matches use the proxy. Proxy routes require
`use_proxy` and are enabled for teams on request.

```python
session = client.sessions.create({
"use_proxy": True,
"proxy_routes": [
{"match": "api.example.com", "proxy": "default"}, # stays on the proxy
{"match": ["*.example.com", "cdn.example.net"], "proxy": "direct"},
],
})
```

A pattern is a hostname, `*.example.com` (its subdomains, not `example.com`
itself), or `*` (every host). Proxy routes only apply to `client.sessions.create`
and cannot be combined with `allow_internet_access=False`, `allow_out`, or
`deny_out`. See the
[proxy routes guide](https://hyperbrowser.ai/docs/sessions/proxy-routes).

## Usage

Hyperbrowser 1.0 accepts plain dictionaries for request parameters. Method
Expand Down
6 changes: 6 additions & 0 deletions hyperbrowser/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,9 @@
SessionProfile,
SessionLaunchState,
StartSessionFromSnapshotParams,
WebBotAuthParams,
ProxyRoute,
SessionWebBotAuth,
UploadFileResponse,
ImageCaptchaParam,
CaptchaSolverType,
Expand Down Expand Up @@ -704,6 +707,9 @@
"SessionProfile",
"SessionLaunchState",
"StartSessionFromSnapshotParams",
"WebBotAuthParams",
"ProxyRoute",
"SessionWebBotAuth",
"UploadFileResponse",
"ImageCaptchaParam",
"CaptchaSolverType",
Expand Down
69 changes: 69 additions & 0 deletions hyperbrowser/models/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ class SessionProfile(BaseModel):
)


class SessionWebBotAuth(BaseModel):
"""
How a session signs its requests with Web Bot Auth.
"""

model_config = ConfigDict(
populate_by_alias=True,
)

signature_agent: str = Field(alias="signatureAgent")
"""The identity the session signs as, e.g. "https://www.hyperbrowser.ai"."""
only_domains: Optional[List[str]] = Field(default=None, alias="onlyDomains")
"""Present when signing is limited to these hostnames and their subdomains."""


class UpdateSessionProfileParams(BaseModel):
"""
Parameters for updating session profile persistence settings.
Expand Down Expand Up @@ -130,6 +145,8 @@ class SessionLaunchState(BaseModel):
)

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

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


class WebBotAuthParams(BaseModel):
"""
Limits Web Bot Auth signing to some hosts.
"""

# An unknown key (such as "onlyDomains") is an error rather than dropped:
# dropping it would sign requests to every host.
model_config = ConfigDict(populate_by_name=True, extra="forbid")

only_domains: Optional[List[str]] = Field(
default=None, serialization_alias="onlyDomains"
)
"""Sign only requests to these hostnames (e.g. "example.com") and their
subdomains. No scheme, port or path; at most 50.
"""


class ProxyRoute(BaseModel):
"""
Sends traffic for matching hosts through the session's proxy or straight out.
"""

model_config = ConfigDict(populate_by_name=True, extra="forbid")

match: Union[str, List[str]]
"""A hostname, "*.example.com" (subdomains, not example.com itself) or "*"
(every host), or a list of them.
"""
proxy: Literal["direct", "default"]
"""Where the traffic goes: "direct" sends it straight out of the browser's
machine, and "default" sends it through the session's proxy.
"""


class CreateSessionParams(BaseModel):
"""
Parameters for creating a new browser session.
Expand All @@ -416,6 +467,24 @@ class CreateSessionParams(BaseModel):
enable_web_mcp: Optional[bool] = Field(
default=None, serialization_alias="enableWebMcp"
)
web_bot_auth: Optional[Union[bool, WebBotAuthParams]] = Field(
default=None, serialization_alias="webBotAuth"
)
"""Sign the session's requests with Web Bot Auth so websites can verify they
come from Hyperbrowser. Pass WebBotAuthParams(only_domains=[...]) to sign only
for those hosts. Requires Web Bot Auth to be enabled for your team; works for
sessions and agent tasks, and cannot be combined with use_ultra_stealth or
start_from_snapshot.
"""
proxy_routes: Optional[List[ProxyRoute]] = Field(
default=None, serialization_alias="proxyRoutes"
)
"""Choose, per host, whether the session's traffic goes through its proxy or
straight out. The first route that matches a host decides; hosts that no
route matches use the proxy. Requires use_proxy and proxy routes to be
enabled for your team, only applies when creating a session, and cannot be
combined with allow_internet_access=False, allow_out or deny_out.
"""

use_ultra_stealth: bool = Field(
default=False, serialization_alias="useUltraStealth"
Expand Down
4 changes: 4 additions & 0 deletions hyperbrowser/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@
SessionListParams,
SessionStatus,
StartSessionFromSnapshotParams,
WebBotAuthParams,
ProxyRoute,
UpdateSessionProfileParams,
UpdateSessionProxyLocationParams,
UpdateSessionProxyParams,
Expand Down Expand Up @@ -323,6 +325,8 @@
"SessionListParams",
"SessionStatus",
"StartSessionFromSnapshotParams",
"WebBotAuthParams",
"ProxyRoute",
"UpdateSessionProfileParams",
"UpdateSessionProxyLocationParams",
"UpdateSessionProxyParams",
Expand Down
19 changes: 18 additions & 1 deletion hyperbrowser/types/session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Literal, Optional
from typing import List, Literal, Optional, Union

from typing_extensions import Required, TypedDict

Expand Down Expand Up @@ -114,10 +114,27 @@ class StartSessionFromSnapshotParams(TypedDict):
snapshot_id: str


class WebBotAuthParams(TypedDict, total=False):
"""Hosts to limit Web Bot Auth signing to: hostnames such as "example.com",
each also covering its subdomains."""

only_domains: List[str]


class ProxyRoute(TypedDict):
"""Sends traffic for matching hosts through the session's proxy ("default")
or straight out ("direct"). A pattern is a hostname, "*.example.com" or "*"."""

match: Union[str, List[str]]
proxy: Literal["direct", "default"]


class CreateSessionParams(TypedDict, total=False):
"""Configuration for creating a browser session."""

enable_web_mcp: Optional[bool]
web_bot_auth: Optional[Union[bool, WebBotAuthParams]]
proxy_routes: Optional[List[ProxyRoute]]
use_ultra_stealth: bool
use_stealth: bool
use_proxy: bool
Expand Down
150 changes: 150 additions & 0 deletions tests/test_session_web_bot_auth_proxy_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import asyncio
import json

import httpx
import pytest
from pydantic import ValidationError

from hyperbrowser import AsyncHyperbrowser, Hyperbrowser
from hyperbrowser.exceptions import HyperbrowserError
from hyperbrowser.models import (
CreateSessionParams,
ProxyRoute,
SessionLaunchState,
WebBotAuthParams,
)


@pytest.fixture(params=[False, True], ids=["sync", "async"])
def sdk(request):
"""Exercise the same wire contract through both public clients and transports."""
is_async = request.param
loop = asyncio.new_event_loop()
client = (AsyncHyperbrowser if is_async else Hyperbrowser)(api_key="test-key")
calls = []

def handler(req):
calls.append(req)
return httpx.Response(400, json={"message": "fixture stops before parsing"})

def call(method, *args, **kwargs):
result = method(*args, **kwargs)
return loop.run_until_complete(result) if is_async else result

call(client.transport.close)
client.transport.client = (httpx.AsyncClient if is_async else httpx.Client)(
transport=httpx.MockTransport(handler),
headers={"x-api-key": "test-key"},
timeout=30,
)
if is_async:
client.transport._closed = False
yield client, call, calls
call(client.close)
loop.close()


def sent_bodies(sdk, method, params_list):
client, call, calls = sdk
for params in params_list:
with pytest.raises(HyperbrowserError):
call(method, params)
return [json.loads(req.content) for req in calls]


def test_web_bot_auth_on_the_wire(sdk):
client = sdk[0]
bodies = sent_bodies(
sdk,
client.sessions.create,
[
{},
{"web_bot_auth": True},
{"web_bot_auth": False},
{"web_bot_auth": {"only_domains": ["example.com", "shop.example.org"]}},
CreateSessionParams(web_bot_auth=True),
CreateSessionParams(
web_bot_auth=WebBotAuthParams(only_domains=["example.com"])
),
],
)
assert "webBotAuth" not in bodies[0]
assert bodies[1]["webBotAuth"] is True
assert bodies[2]["webBotAuth"] is False
assert bodies[3]["webBotAuth"] == {
"onlyDomains": ["example.com", "shop.example.org"]
}
assert bodies[4]["webBotAuth"] is True
assert bodies[5]["webBotAuth"] == {"onlyDomains": ["example.com"]}


def test_proxy_routes_on_the_wire_in_order(sdk):
client = sdk[0]
routes = [
{"match": "api.example.com", "proxy": "default"},
{"match": ["*.example.com", "cdn.example.net"], "proxy": "direct"},
]
bodies = sent_bodies(
sdk,
client.sessions.create,
[
{"use_proxy": True, "proxy_routes": routes},
CreateSessionParams(
use_proxy=True,
proxy_routes=[ProxyRoute(**route) for route in routes],
),
],
)
for body in bodies:
assert body["useProxy"] is True
assert body["proxyRoutes"] == routes


def test_agent_tasks_pass_web_bot_auth_through_session_options(sdk):
client = sdk[0]
bodies = sent_bodies(
sdk,
client.agents.browser_use.start,
[
{
"task": "Open example.com",
"session_options": {"web_bot_auth": {"only_domains": ["example.com"]}},
}
],
)
assert sdk[2][0].url.path == "/api/task/browser-use"
assert bodies[0]["sessionOptions"]["webBotAuth"] == {"onlyDomains": ["example.com"]}


def test_misspelled_or_invalid_options_fail_before_sending(sdk):
client, call, calls = sdk
for params in [
# A dropped key would sign requests to every host instead of example.com.
{"web_bot_auth": {"onlyDomains": ["example.com"]}},
{"proxy_routes": [{"match": "example.com", "proxy": "upstream"}]},
{"proxy_routes": [{"match": "example.com"}]},
{"proxy_routes": [{"match": "example.com", "proxy": "direct", "port": 443}]},
]:
with pytest.raises(ValidationError):
call(client.sessions.create, params)
assert calls == []


def test_launch_state_reports_how_a_session_signs():
state = SessionLaunchState.model_validate(
{
"webBotAuth": {
"signatureAgent": "https://www.hyperbrowser.ai",
"onlyDomains": ["example.com"],
}
}
)
assert state.web_bot_auth is not None
assert state.web_bot_auth.signature_agent == "https://www.hyperbrowser.ai"
assert state.web_bot_auth.only_domains == ["example.com"]
unscoped = SessionLaunchState.model_validate(
{"webBotAuth": {"signatureAgent": "https://www.hyperbrowser.ai"}}
)
assert unscoped.web_bot_auth is not None
assert unscoped.web_bot_auth.only_domains is None
assert SessionLaunchState().web_bot_auth is None
7 changes: 7 additions & 0 deletions tests/typecheck/invalid_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,10 @@ def invalid_webmcp_requests(client: Hyperbrowser) -> None:
async def invalid_async_webmcp_requests(client: AsyncHyperbrowser) -> None:
await client.sessions.webmcp.invoke("s", {"tool_ref": "t", "timeout_seconds": "60"}) # M,P
await client.sessions.webmcp.get_result("s", "i", {"waitSeconds": 30}) # M,P


def invalid_web_bot_auth_and_proxy_routes_requests(client: Hyperbrowser) -> None:
client.sessions.create({"web_bot_auth": {"onlyDomains": ["a.com"]}}) # M,P
client.sessions.create({"web_bot_auth": "yes"}) # M,P
client.sessions.create({"proxy_routes": [{"match": "a.com"}]}) # M,P
client.sessions.create({"proxy_routes": [{"match": "*", "proxy": "up"}]}) # M,P
Loading