diff --git a/README.md b/README.md index 105d4163..b20de38e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 017c4cec..7df4d6cb 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -343,6 +343,9 @@ SessionProfile, SessionLaunchState, StartSessionFromSnapshotParams, + WebBotAuthParams, + ProxyRoute, + SessionWebBotAuth, UploadFileResponse, ImageCaptchaParam, CaptchaSolverType, @@ -704,6 +707,9 @@ "SessionProfile", "SessionLaunchState", "StartSessionFromSnapshotParams", + "WebBotAuthParams", + "ProxyRoute", + "SessionWebBotAuth", "UploadFileResponse", "ImageCaptchaParam", "CaptchaSolverType", diff --git a/hyperbrowser/models/session.py b/hyperbrowser/models/session.py index a40249c0..870d6a2f 100644 --- a/hyperbrowser/models/session.py +++ b/hyperbrowser/models/session.py @@ -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. @@ -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") @@ -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. @@ -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" diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index 19610e47..44689479 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -147,6 +147,8 @@ SessionListParams, SessionStatus, StartSessionFromSnapshotParams, + WebBotAuthParams, + ProxyRoute, UpdateSessionProfileParams, UpdateSessionProxyLocationParams, UpdateSessionProxyParams, @@ -323,6 +325,8 @@ "SessionListParams", "SessionStatus", "StartSessionFromSnapshotParams", + "WebBotAuthParams", + "ProxyRoute", "UpdateSessionProfileParams", "UpdateSessionProxyLocationParams", "UpdateSessionProxyParams", diff --git a/hyperbrowser/types/session.py b/hyperbrowser/types/session.py index a5b8d56a..7e7a9ed1 100644 --- a/hyperbrowser/types/session.py +++ b/hyperbrowser/types/session.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional +from typing import List, Literal, Optional, Union from typing_extensions import Required, TypedDict @@ -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 diff --git a/tests/test_session_web_bot_auth_proxy_routes.py b/tests/test_session_web_bot_auth_proxy_routes.py new file mode 100644 index 00000000..9e66e157 --- /dev/null +++ b/tests/test_session_web_bot_auth_proxy_routes.py @@ -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 diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index 7d2cea30..15dc0add 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -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 diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index 5ae35200..15bb5a09 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -533,3 +533,38 @@ async def valid_async_webmcp_requests(client: AsyncHyperbrowser) -> None: "session", handle.invocation_id, {"wait_seconds": 30} ) await client.sessions.webmcp.cancel("session", handle.invocation_id) + + +def valid_web_bot_auth_and_proxy_routes_requests(client: Hyperbrowser) -> None: + from hyperbrowser.models import ProxyRoute, WebBotAuthParams + + client.sessions.create({"web_bot_auth": True}) + client.sessions.create({"web_bot_auth": {"only_domains": ["example.com"]}}) + client.sessions.create( + { + "use_proxy": True, + "proxy_routes": [ + {"match": "api.example.com", "proxy": "default"}, + {"match": ["*.example.com", "cdn.example.net"], "proxy": "direct"}, + ], + } + ) + client.sessions.create( + LegacyCreateSessionParams( + web_bot_auth=WebBotAuthParams(only_domains=["example.com"]), + use_proxy=True, + proxy_routes=[ProxyRoute(match="*", proxy="direct")], + ) + ) + client.agents.browser_use.start( + {"task": "Open example.com", "session_options": {"web_bot_auth": True}} + ) + + +async def valid_async_web_bot_auth_and_proxy_routes_requests( + client: AsyncHyperbrowser, +) -> None: + await client.sessions.create({"web_bot_auth": {"only_domains": ["example.com"]}}) + await client.sessions.create( + {"use_proxy": True, "proxy_routes": [{"match": "*", "proxy": "direct"}]} + )