From 7ca68877e7011bb83862b7cc810a20d8254ea7dd Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 11:51:39 -0400 Subject: [PATCH 01/17] feat: add browser-scoped session client Bind browser subresource calls to a browser session's base_url and expose raw HTTP through request and stream helpers so metro-routed access feels like normal httpx usage. Made-with: Cursor --- examples/browser_scoped.py | 19 ++ src/kernel/_client.py | 12 + src/kernel/lib/browser_scoped/__init__.py | 3 + src/kernel/lib/browser_scoped/client.py | 311 ++++++++++++++++++ src/kernel/lib/browser_scoped/metro_client.py | 92 ++++++ src/kernel/lib/browser_scoped/util.py | 45 +++ tests/test_browser_scoped.py | 84 +++++ 7 files changed, 566 insertions(+) create mode 100644 examples/browser_scoped.py create mode 100644 src/kernel/lib/browser_scoped/__init__.py create mode 100644 src/kernel/lib/browser_scoped/client.py create mode 100644 src/kernel/lib/browser_scoped/metro_client.py create mode 100644 src/kernel/lib/browser_scoped/util.py create mode 100644 tests/test_browser_scoped.py diff --git a/examples/browser_scoped.py b/examples/browser_scoped.py new file mode 100644 index 00000000..81159686 --- /dev/null +++ b/examples/browser_scoped.py @@ -0,0 +1,19 @@ +"""Example: browser-scoped client for metro-backed process and raw HTTP.""" + +from kernel import Kernel + +# After creating or loading a browser session (with base_url + cdp_ws_url from the API): +# browser = client.browsers.create(...) +# scoped = client.for_browser(browser) +# scoped.process.exec(command="uname", args=["-a"]) +# r = scoped.request("GET", "https://example.com") +# with scoped.stream("GET", "https://example.com") as resp: +# print(resp.read()) + + +def main() -> None: + _ = Kernel + + +if __name__ == "__main__": + main() diff --git a/src/kernel/_client.py b/src/kernel/_client.py index 75fe4b64..ad0adfad 100644 --- a/src/kernel/_client.py +++ b/src/kernel/_client.py @@ -319,6 +319,12 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy + def for_browser(self, browser: Any) -> Any: + """Return a browser-scoped client for session subresources and raw HTTP through the session base_url.""" + from .lib.browser_scoped.client import browser_scoped_from_browser + + return browser_scoped_from_browser(self, browser) + @override def _make_status_error( self, @@ -596,6 +602,12 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy + def for_browser(self, browser: Any) -> Any: + """Return a browser-scoped client for session subresources and raw HTTP through the session base_url.""" + from .lib.browser_scoped.client import async_browser_scoped_from_browser + + return async_browser_scoped_from_browser(self, browser) + @override def _make_status_error( self, diff --git a/src/kernel/lib/browser_scoped/__init__.py b/src/kernel/lib/browser_scoped/__init__.py new file mode 100644 index 00000000..10e15438 --- /dev/null +++ b/src/kernel/lib/browser_scoped/__init__.py @@ -0,0 +1,3 @@ +from .client import BrowserScopedClient, AsyncBrowserScopedClient + +__all__ = ["BrowserScopedClient", "AsyncBrowserScopedClient"] diff --git a/src/kernel/lib/browser_scoped/client.py b/src/kernel/lib/browser_scoped/client.py new file mode 100644 index 00000000..02cae472 --- /dev/null +++ b/src/kernel/lib/browser_scoped/client.py @@ -0,0 +1,311 @@ +"""Browser-scoped view over a session: metro-routed subresources and raw HTTP via /curl/raw.""" + +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, Any, Mapping, cast +from contextlib import contextmanager, asynccontextmanager +from collections.abc import Iterator, AsyncIterator + +import httpx + +from .util import ( + jwt_from_cdp_ws_url, + base_url_from_browser_like, + cdp_ws_url_from_browser_like, + session_id_from_browser_like, +) +from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given +from ..._models import FinalRequestOptions +from .metro_client import metro_kernel_from_browser, metro_async_kernel_from_browser + +if TYPE_CHECKING: + from ..._client import Kernel, AsyncKernel + from ...resources.browsers.logs import LogsResource, AsyncLogsResource + from ...resources.browsers.fs.fs import FsResource, AsyncFsResource + from ...resources.browsers.process import ProcessResource, AsyncProcessResource + from ...resources.browsers.replays import ReplaysResource, AsyncReplaysResource + from ...resources.browsers.computer import ComputerResource, AsyncComputerResource + from ...resources.browsers.playwright import PlaywrightResource, AsyncPlaywrightResource + + +class _BoundBrowserSubresource: + """Delegates to a generated resource while defaulting `id` to the scoped session.""" + + def __init__(self, inner: Any, session_id: str) -> None: + object.__setattr__(self, "_inner", inner) + object.__setattr__(self, "_session_id", session_id) + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError(name) + attr = getattr(self._inner, name) + if name.startswith("with_") or not callable(attr): + return attr + try: + sig = inspect.signature(attr) + except (TypeError, ValueError): + return attr + if "id" not in sig.parameters: + return attr + + def bound(*args: Any, **kwargs: Any) -> Any: + kw = dict(kwargs) + kw["id"] = self._session_id + return attr(*args, **kw) + + return bound + + +class BrowserScopedClient: + """Session-scoped API: subresources without repeating session id; HTTP via browser /curl/raw.""" + + def __init__(self, parent: Kernel, *, session_id: str, metro_base_url: str, jwt: str) -> None: + self._parent = parent + self.session_id = session_id + self._metro_base_url = metro_base_url + self._jwt = jwt + self._metro = metro_kernel_from_browser(parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt) + + @property + def parent(self) -> Kernel: + """Control-plane client this view was created from (for future id remapping hooks).""" + return self._parent + + @property + def base_url(self) -> str: + return self._metro_base_url + + @property + def process(self) -> ProcessResource: + from ...resources.browsers.process import ProcessResource + + return cast(ProcessResource, _BoundBrowserSubresource(ProcessResource(self._metro), self.session_id)) + + @property + def computer(self) -> ComputerResource: + from ...resources.browsers.computer import ComputerResource + + return cast(ComputerResource, _BoundBrowserSubresource(ComputerResource(self._metro), self.session_id)) + + @property + def fs(self) -> FsResource: + from ...resources.browsers.fs.fs import FsResource + + return cast(FsResource, _BoundBrowserSubresource(FsResource(self._metro), self.session_id)) + + @property + def logs(self) -> LogsResource: + from ...resources.browsers.logs import LogsResource + + return cast(LogsResource, _BoundBrowserSubresource(LogsResource(self._metro), self.session_id)) + + @property + def playwright(self) -> PlaywrightResource: + from ...resources.browsers.playwright import PlaywrightResource + + return cast(PlaywrightResource, _BoundBrowserSubresource(PlaywrightResource(self._metro), self.session_id)) + + @property + def replays(self) -> ReplaysResource: + from ...resources.browsers.replays import ReplaysResource + + return cast(ReplaysResource, _BoundBrowserSubresource(ReplaysResource(self._metro), self.session_id)) + + def request( + self, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + json: Body | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> httpx.Response: + if json is not None and content is not None: + raise TypeError("Passing both `json` and `content` is not supported") + q: dict[str, object] = {"url": url} + if params: + q.update(dict(params)) + opts = FinalRequestOptions.construct( + method=method.upper(), + url="/curl/raw", + params=q, + headers=headers if headers is not None else not_given, + content=content, + json_data=json, + timeout=timeout, + ) + return self._metro.request(httpx.Response, opts) + + @contextmanager + def stream( + self, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> Iterator[httpx.Response]: + q: dict[str, Any] = dict(self._metro.default_query) + q["url"] = url + if params: + q.update(dict(params)) + h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)} + if content is None: + h.pop("Content-Type", None) + if headers: + h.update(headers) + eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout + cm = self._metro._client.stream( + method.upper(), + self._metro._prepare_url("/curl/raw"), + params=q, + headers=h, + content=content, + timeout=eff_timeout, + ) + with cm as resp: + yield resp + + +class AsyncBrowserScopedClient: + def __init__(self, parent: AsyncKernel, *, session_id: str, metro_base_url: str, jwt: str) -> None: + self._parent = parent + self.session_id = session_id + self._metro_base_url = metro_base_url + self._jwt = jwt + self._metro = metro_async_kernel_from_browser( + parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt + ) + + @property + def parent(self) -> AsyncKernel: + return self._parent + + @property + def base_url(self) -> str: + return self._metro_base_url + + @property + def process(self) -> AsyncProcessResource: + from ...resources.browsers.process import AsyncProcessResource + + return cast(AsyncProcessResource, _BoundBrowserSubresource(AsyncProcessResource(self._metro), self.session_id)) + + @property + def computer(self) -> AsyncComputerResource: + from ...resources.browsers.computer import AsyncComputerResource + + return cast( + AsyncComputerResource, _BoundBrowserSubresource(AsyncComputerResource(self._metro), self.session_id) + ) + + @property + def fs(self) -> AsyncFsResource: + from ...resources.browsers.fs.fs import AsyncFsResource + + return cast(AsyncFsResource, _BoundBrowserSubresource(AsyncFsResource(self._metro), self.session_id)) + + @property + def logs(self) -> AsyncLogsResource: + from ...resources.browsers.logs import AsyncLogsResource + + return cast(AsyncLogsResource, _BoundBrowserSubresource(AsyncLogsResource(self._metro), self.session_id)) + + @property + def playwright(self) -> AsyncPlaywrightResource: + from ...resources.browsers.playwright import AsyncPlaywrightResource + + return cast( + AsyncPlaywrightResource, _BoundBrowserSubresource(AsyncPlaywrightResource(self._metro), self.session_id) + ) + + @property + def replays(self) -> AsyncReplaysResource: + from ...resources.browsers.replays import AsyncReplaysResource + + return cast(AsyncReplaysResource, _BoundBrowserSubresource(AsyncReplaysResource(self._metro), self.session_id)) + + async def request( + self, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + json: Body | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> httpx.Response: + if json is not None and content is not None: + raise TypeError("Passing both `json` and `content` is not supported") + q: dict[str, object] = {"url": url} + if params: + q.update(dict(params)) + opts = FinalRequestOptions.construct( + method=method.upper(), + url="/curl/raw", + params=q, + headers=headers if headers is not None else not_given, + content=content, + json_data=json, + timeout=timeout, + ) + return await self._metro.request(httpx.Response, opts) + + @asynccontextmanager + async def stream( + self, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, + ) -> AsyncIterator[httpx.Response]: + q: dict[str, Any] = dict(self._metro.default_query) + q["url"] = url + if params: + q.update(dict(params)) + h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)} + if content is None: + h.pop("Content-Type", None) + if headers: + h.update(headers) + eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout + async with self._metro._client.stream( + method.upper(), + self._metro._prepare_url("/curl/raw"), + params=q, + headers=h, + content=content, + timeout=eff_timeout, + ) as resp: + yield resp + + +def browser_scoped_from_browser(parent: Kernel, browser: Any) -> BrowserScopedClient: + session_id = session_id_from_browser_like(browser) + metro = base_url_from_browser_like(browser) + if not metro: + raise ValueError("browser.base_url is required for a browser-scoped client") + jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) + if not jwt: + raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests") + return BrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt) + + +def async_browser_scoped_from_browser(parent: AsyncKernel, browser: Any) -> AsyncBrowserScopedClient: + session_id = session_id_from_browser_like(browser) + metro = base_url_from_browser_like(browser) + if not metro: + raise ValueError("browser.base_url is required for a browser-scoped client") + jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) + if not jwt: + raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests") + return AsyncBrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt) diff --git a/src/kernel/lib/browser_scoped/metro_client.py b/src/kernel/lib/browser_scoped/metro_client.py new file mode 100644 index 00000000..27b794da --- /dev/null +++ b/src/kernel/lib/browser_scoped/metro_client.py @@ -0,0 +1,92 @@ +"""Internal HTTP clients that speak to metro-api /browser/kernel paths.""" + +from __future__ import annotations + +from typing import Any, cast + +from ..._client import Kernel, AsyncKernel +from ..._compat import model_copy +from ..._models import FinalRequestOptions + + +class _BrowserMetroKernel(Kernel): + """Kernel client clone whose requests hit metro base_url with /browsers/{id} stripped.""" + + _scoped_session_id: str + + def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None: + self._scoped_session_id = browser_session_id + super().__init__(**kwargs) + + def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + options = super()._prepare_options(options) + url = options.url + if not isinstance(url, str): + return options + prefix = f"/browsers/{self._scoped_session_id}/" + if not url.startswith(prefix): + return options + suffix = url[len(prefix) :].lstrip("/") + new_url = f"/{suffix}" if suffix else "/" + out = model_copy(options) + out.url = new_url + return cast(FinalRequestOptions, out) + + +class _BrowserMetroAsyncKernel(AsyncKernel): + _scoped_session_id: str + + def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None: + self._scoped_session_id = browser_session_id + super().__init__(**kwargs) + + async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: + options = await super()._prepare_options(options) + url = options.url + if not isinstance(url, str): + return options + prefix = f"/browsers/{self._scoped_session_id}/" + if not url.startswith(prefix): + return options + suffix = url[len(prefix) :].lstrip("/") + new_url = f"/{suffix}" if suffix else "/" + out = model_copy(options) + out.url = new_url + return cast(FinalRequestOptions, out) + + +def metro_kernel_from_browser(parent: Kernel, *, session_id: str, metro_base_url: str, jwt: str) -> _BrowserMetroKernel: + """Build a sync metro-scoped client sharing the parent's httpx transport.""" + base_q = getattr(parent, "_custom_query", None) or {} + dq = {str(k): v for k, v in dict(base_q).items()} + dq["jwt"] = jwt + return _BrowserMetroKernel( + browser_session_id=session_id, + api_key=parent.api_key, + base_url=metro_base_url, + timeout=parent.timeout, + max_retries=parent.max_retries, + http_client=parent._client, + default_headers=dict(parent._custom_headers), + default_query=dq, + _strict_response_validation=getattr(parent, "_strict_response_validation", False), + ) + + +def metro_async_kernel_from_browser( + parent: AsyncKernel, *, session_id: str, metro_base_url: str, jwt: str +) -> _BrowserMetroAsyncKernel: + base_q = getattr(parent, "_custom_query", None) or {} + dq = {str(k): v for k, v in dict(base_q).items()} + dq["jwt"] = jwt + return _BrowserMetroAsyncKernel( + browser_session_id=session_id, + api_key=parent.api_key, + base_url=metro_base_url, + timeout=parent.timeout, + max_retries=parent.max_retries, + http_client=parent._client, + default_headers=dict(parent._custom_headers), + default_query=dq, + _strict_response_validation=getattr(parent, "_strict_response_validation", False), + ) diff --git a/src/kernel/lib/browser_scoped/util.py b/src/kernel/lib/browser_scoped/util.py new file mode 100644 index 00000000..5063a417 --- /dev/null +++ b/src/kernel/lib/browser_scoped/util.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any, Mapping +from urllib.parse import parse_qs, urlparse + + +def jwt_from_cdp_ws_url(cdp_ws_url: str) -> str | None: + parsed = urlparse(cdp_ws_url) + values = parse_qs(parsed.query).get("jwt") + if not values: + return None + return values[0] + + +def session_id_from_browser_like(browser: Any) -> str: + sid = getattr(browser, "session_id", None) + if isinstance(sid, str) and sid: + return sid + if isinstance(browser, Mapping): + m = browser.get("session_id") + if isinstance(m, str) and m: + return m + raise TypeError("browser object must have a non-empty session_id") + + +def base_url_from_browser_like(browser: Any) -> str | None: + bu = getattr(browser, "base_url", None) + if isinstance(bu, str) and bu.strip(): + return bu.strip().rstrip("/") + "/" + if isinstance(browser, Mapping): + raw = browser.get("base_url") + if isinstance(raw, str) and raw.strip(): + return raw.strip().rstrip("/") + "/" + return None + + +def cdp_ws_url_from_browser_like(browser: Any) -> str: + u = getattr(browser, "cdp_ws_url", None) + if isinstance(u, str) and u: + return u + if isinstance(browser, Mapping): + m = browser.get("cdp_ws_url") + if isinstance(m, str) and m: + return m + raise TypeError("browser object must have a non-empty cdp_ws_url") diff --git a/tests/test_browser_scoped.py b/tests/test_browser_scoped.py new file mode 100644 index 00000000..5cdd8911 --- /dev/null +++ b/tests/test_browser_scoped.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import os +import json + +import httpx +import respx +import pytest + +from kernel import Kernel +from kernel.lib.browser_scoped.util import jwt_from_cdp_ws_url + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "sk-123" + + +def _fake_browser() -> dict[str, str]: + return { + "session_id": "sess-1", + "base_url": "http://metro.test/browser/kernel", + "cdp_ws_url": "wss://metro.test/browser/cdp?jwt=token-abc", + "webdriver_ws_url": "wss://x", + "created_at": "2020-01-01T00:00:00Z", + "headless": True, + "stealth": False, + "timeout_seconds": 60, + } + + +def test_jwt_from_cdp_ws_url() -> None: + assert jwt_from_cdp_ws_url("wss://h/browser/cdp?jwt=abc%2Fdef&x=1") == "abc/def" + + +@respx.mock +def test_for_browser_process_exec_routes_to_metro() -> None: + metro = respx.post("http://metro.test/browser/kernel/process/exec?jwt=token-abc").mock( + return_value=httpx.Response( + 200, + json={ + "exit_code": 0, + "stdout_b64": "", + "stderr_b64": "", + }, + ) + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + b = client.for_browser(_fake_browser()) + out = b.process.exec(command="echo", args=["hi"]) + assert metro.called + sent = metro.calls[0].request.read().decode() + body = json.loads(sent) + assert body["command"] == "echo" + assert body["args"] == ["hi"] + assert out.exit_code == 0 + + +@respx.mock +def test_browser_request_uses_curl_raw() -> None: + route = respx.get("http://metro.test/browser/kernel/curl/raw").mock(return_value=httpx.Response(200, content=b"ok")) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + b = client.for_browser(_fake_browser()) + r = b.request("GET", "https://example.com", params={"timeout_ms": 5000}) + assert r.status_code == 200 + assert r.content == b"ok" + assert route.called + assert "curl/raw" in str(route.calls[0].request.url) + assert "jwt=token-abc" in str(route.calls[0].request.url) + + +@respx.mock +def test_browser_stream_reads_body() -> None: + respx.get("http://metro.test/browser/kernel/curl/raw").mock(return_value=httpx.Response(200, content=b"streamed")) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + b = client.for_browser(_fake_browser()) + with b.stream("GET", "https://example.com") as resp: + assert resp.status_code == 200 + assert resp.read() == b"streamed" + + +def test_for_browser_requires_base_url() -> None: + bad = {**_fake_browser(), "base_url": None} + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + with pytest.raises(ValueError, match="base_url"): + client.for_browser(bad) From b2c7aacac09a1bb7680cf493e9985438b169286c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:09:22 -0400 Subject: [PATCH 02/17] fix: reserve internal browser request query params Prevent browser-scoped raw HTTP helpers from letting user params override internal routing query keys, and clean up wording around browser session base_url routing. Made-with: Cursor --- examples/browser_scoped.py | 2 +- ...ro_client.py => browser_session_kernel.py} | 28 ++--- src/kernel/lib/browser_scoped/client.py | 103 +++++++++--------- src/kernel/lib/browser_scoped/util.py | 10 ++ src/kernel/types/browser_create_response.py | 2 +- src/kernel/types/browser_list_response.py | 2 +- .../types/browser_pool_acquire_response.py | 2 +- src/kernel/types/browser_retrieve_response.py | 2 +- src/kernel/types/browser_update_response.py | 2 +- .../invocation_list_browsers_response.py | 2 +- tests/test_browser_scoped.py | 59 ++++++++-- 11 files changed, 133 insertions(+), 81 deletions(-) rename src/kernel/lib/browser_scoped/{metro_client.py => browser_session_kernel.py} (77%) diff --git a/examples/browser_scoped.py b/examples/browser_scoped.py index 81159686..e409517a 100644 --- a/examples/browser_scoped.py +++ b/examples/browser_scoped.py @@ -1,4 +1,4 @@ -"""Example: browser-scoped client for metro-backed process and raw HTTP.""" +"""Example: browser-scoped client for browser VM process exec and raw HTTP.""" from kernel import Kernel diff --git a/src/kernel/lib/browser_scoped/metro_client.py b/src/kernel/lib/browser_scoped/browser_session_kernel.py similarity index 77% rename from src/kernel/lib/browser_scoped/metro_client.py rename to src/kernel/lib/browser_scoped/browser_session_kernel.py index 27b794da..6be815bd 100644 --- a/src/kernel/lib/browser_scoped/metro_client.py +++ b/src/kernel/lib/browser_scoped/browser_session_kernel.py @@ -1,4 +1,4 @@ -"""Internal HTTP clients that speak to metro-api /browser/kernel paths.""" +"""Internal Kernel clones for browser session HTTP (base_url + /browser/kernel paths).""" from __future__ import annotations @@ -9,8 +9,8 @@ from ..._models import FinalRequestOptions -class _BrowserMetroKernel(Kernel): - """Kernel client clone whose requests hit metro base_url with /browsers/{id} stripped.""" +class _BrowserSessionKernel(Kernel): + """Kernel clone whose HTTP base is the browser session; strips /browsers/{id} from paths.""" _scoped_session_id: str @@ -33,7 +33,7 @@ def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: return cast(FinalRequestOptions, out) -class _BrowserMetroAsyncKernel(AsyncKernel): +class _BrowserSessionAsyncKernel(AsyncKernel): _scoped_session_id: str def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None: @@ -55,15 +55,17 @@ async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOp return cast(FinalRequestOptions, out) -def metro_kernel_from_browser(parent: Kernel, *, session_id: str, metro_base_url: str, jwt: str) -> _BrowserMetroKernel: - """Build a sync metro-scoped client sharing the parent's httpx transport.""" +def build_browser_session_kernel( + parent: Kernel, *, session_id: str, session_base_url: str, jwt: str +) -> _BrowserSessionKernel: + """Build a sync client sharing the parent's httpx transport; requests use session_base_url.""" base_q = getattr(parent, "_custom_query", None) or {} dq = {str(k): v for k, v in dict(base_q).items()} dq["jwt"] = jwt - return _BrowserMetroKernel( + return _BrowserSessionKernel( browser_session_id=session_id, api_key=parent.api_key, - base_url=metro_base_url, + base_url=session_base_url, timeout=parent.timeout, max_retries=parent.max_retries, http_client=parent._client, @@ -73,16 +75,16 @@ def metro_kernel_from_browser(parent: Kernel, *, session_id: str, metro_base_url ) -def metro_async_kernel_from_browser( - parent: AsyncKernel, *, session_id: str, metro_base_url: str, jwt: str -) -> _BrowserMetroAsyncKernel: +def build_async_browser_session_kernel( + parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str +) -> _BrowserSessionAsyncKernel: base_q = getattr(parent, "_custom_query", None) or {} dq = {str(k): v for k, v in dict(base_q).items()} dq["jwt"] = jwt - return _BrowserMetroAsyncKernel( + return _BrowserSessionAsyncKernel( browser_session_id=session_id, api_key=parent.api_key, - base_url=metro_base_url, + base_url=session_base_url, timeout=parent.timeout, max_retries=parent.max_retries, http_client=parent._client, diff --git a/src/kernel/lib/browser_scoped/client.py b/src/kernel/lib/browser_scoped/client.py index 02cae472..99b3ed2d 100644 --- a/src/kernel/lib/browser_scoped/client.py +++ b/src/kernel/lib/browser_scoped/client.py @@ -1,4 +1,4 @@ -"""Browser-scoped view over a session: metro-routed subresources and raw HTTP via /curl/raw.""" +"""Browser-scoped view over a session: VM subresources and raw HTTP via internal /curl/raw.""" from __future__ import annotations @@ -11,13 +11,14 @@ from .util import ( jwt_from_cdp_ws_url, + sanitize_curl_raw_params, base_url_from_browser_like, cdp_ws_url_from_browser_like, session_id_from_browser_like, ) from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given from ..._models import FinalRequestOptions -from .metro_client import metro_kernel_from_browser, metro_async_kernel_from_browser +from .browser_session_kernel import build_browser_session_kernel, build_async_browser_session_kernel if TYPE_CHECKING: from ..._client import Kernel, AsyncKernel @@ -60,12 +61,14 @@ def bound(*args: Any, **kwargs: Any) -> Any: class BrowserScopedClient: """Session-scoped API: subresources without repeating session id; HTTP via browser /curl/raw.""" - def __init__(self, parent: Kernel, *, session_id: str, metro_base_url: str, jwt: str) -> None: + def __init__(self, parent: Kernel, *, session_id: str, session_base_url: str, jwt: str) -> None: self._parent = parent self.session_id = session_id - self._metro_base_url = metro_base_url + self._session_base_url = session_base_url self._jwt = jwt - self._metro = metro_kernel_from_browser(parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt) + self._http = build_browser_session_kernel( + parent, session_id=session_id, session_base_url=session_base_url, jwt=jwt + ) @property def parent(self) -> Kernel: @@ -74,43 +77,43 @@ def parent(self) -> Kernel: @property def base_url(self) -> str: - return self._metro_base_url + return self._session_base_url @property def process(self) -> ProcessResource: from ...resources.browsers.process import ProcessResource - return cast(ProcessResource, _BoundBrowserSubresource(ProcessResource(self._metro), self.session_id)) + return cast(ProcessResource, _BoundBrowserSubresource(ProcessResource(self._http), self.session_id)) @property def computer(self) -> ComputerResource: from ...resources.browsers.computer import ComputerResource - return cast(ComputerResource, _BoundBrowserSubresource(ComputerResource(self._metro), self.session_id)) + return cast(ComputerResource, _BoundBrowserSubresource(ComputerResource(self._http), self.session_id)) @property def fs(self) -> FsResource: from ...resources.browsers.fs.fs import FsResource - return cast(FsResource, _BoundBrowserSubresource(FsResource(self._metro), self.session_id)) + return cast(FsResource, _BoundBrowserSubresource(FsResource(self._http), self.session_id)) @property def logs(self) -> LogsResource: from ...resources.browsers.logs import LogsResource - return cast(LogsResource, _BoundBrowserSubresource(LogsResource(self._metro), self.session_id)) + return cast(LogsResource, _BoundBrowserSubresource(LogsResource(self._http), self.session_id)) @property def playwright(self) -> PlaywrightResource: from ...resources.browsers.playwright import PlaywrightResource - return cast(PlaywrightResource, _BoundBrowserSubresource(PlaywrightResource(self._metro), self.session_id)) + return cast(PlaywrightResource, _BoundBrowserSubresource(PlaywrightResource(self._http), self.session_id)) @property def replays(self) -> ReplaysResource: from ...resources.browsers.replays import ReplaysResource - return cast(ReplaysResource, _BoundBrowserSubresource(ReplaysResource(self._metro), self.session_id)) + return cast(ReplaysResource, _BoundBrowserSubresource(ReplaysResource(self._http), self.session_id)) def request( self, @@ -125,9 +128,7 @@ def request( ) -> httpx.Response: if json is not None and content is not None: raise TypeError("Passing both `json` and `content` is not supported") - q: dict[str, object] = {"url": url} - if params: - q.update(dict(params)) + q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} opts = FinalRequestOptions.construct( method=method.upper(), url="/curl/raw", @@ -137,7 +138,7 @@ def request( json_data=json, timeout=timeout, ) - return self._metro.request(httpx.Response, opts) + return self._http.request(httpx.Response, opts) @contextmanager def stream( @@ -150,19 +151,18 @@ def stream( params: Mapping[str, object] | None = None, timeout: float | Timeout | None | NotGiven = not_given, ) -> Iterator[httpx.Response]: - q: dict[str, Any] = dict(self._metro.default_query) + q: dict[str, Any] = dict(self._http.default_query) + q.update(sanitize_curl_raw_params(params)) q["url"] = url - if params: - q.update(dict(params)) - h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)} + h = {k: v for k, v in self._http.default_headers.items() if isinstance(v, str)} if content is None: h.pop("Content-Type", None) if headers: h.update(headers) - eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout - cm = self._metro._client.stream( + eff_timeout = self._http.timeout if isinstance(timeout, NotGiven) else timeout + cm = self._http._client.stream( method.upper(), - self._metro._prepare_url("/curl/raw"), + self._http._prepare_url("/curl/raw"), params=q, headers=h, content=content, @@ -173,13 +173,13 @@ def stream( class AsyncBrowserScopedClient: - def __init__(self, parent: AsyncKernel, *, session_id: str, metro_base_url: str, jwt: str) -> None: + def __init__(self, parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str) -> None: self._parent = parent self.session_id = session_id - self._metro_base_url = metro_base_url + self._session_base_url = session_base_url self._jwt = jwt - self._metro = metro_async_kernel_from_browser( - parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt + self._http = build_async_browser_session_kernel( + parent, session_id=session_id, session_base_url=session_base_url, jwt=jwt ) @property @@ -188,47 +188,47 @@ def parent(self) -> AsyncKernel: @property def base_url(self) -> str: - return self._metro_base_url + return self._session_base_url @property def process(self) -> AsyncProcessResource: from ...resources.browsers.process import AsyncProcessResource - return cast(AsyncProcessResource, _BoundBrowserSubresource(AsyncProcessResource(self._metro), self.session_id)) + return cast(AsyncProcessResource, _BoundBrowserSubresource(AsyncProcessResource(self._http), self.session_id)) @property def computer(self) -> AsyncComputerResource: from ...resources.browsers.computer import AsyncComputerResource return cast( - AsyncComputerResource, _BoundBrowserSubresource(AsyncComputerResource(self._metro), self.session_id) + AsyncComputerResource, _BoundBrowserSubresource(AsyncComputerResource(self._http), self.session_id) ) @property def fs(self) -> AsyncFsResource: from ...resources.browsers.fs.fs import AsyncFsResource - return cast(AsyncFsResource, _BoundBrowserSubresource(AsyncFsResource(self._metro), self.session_id)) + return cast(AsyncFsResource, _BoundBrowserSubresource(AsyncFsResource(self._http), self.session_id)) @property def logs(self) -> AsyncLogsResource: from ...resources.browsers.logs import AsyncLogsResource - return cast(AsyncLogsResource, _BoundBrowserSubresource(AsyncLogsResource(self._metro), self.session_id)) + return cast(AsyncLogsResource, _BoundBrowserSubresource(AsyncLogsResource(self._http), self.session_id)) @property def playwright(self) -> AsyncPlaywrightResource: from ...resources.browsers.playwright import AsyncPlaywrightResource return cast( - AsyncPlaywrightResource, _BoundBrowserSubresource(AsyncPlaywrightResource(self._metro), self.session_id) + AsyncPlaywrightResource, _BoundBrowserSubresource(AsyncPlaywrightResource(self._http), self.session_id) ) @property def replays(self) -> AsyncReplaysResource: from ...resources.browsers.replays import AsyncReplaysResource - return cast(AsyncReplaysResource, _BoundBrowserSubresource(AsyncReplaysResource(self._metro), self.session_id)) + return cast(AsyncReplaysResource, _BoundBrowserSubresource(AsyncReplaysResource(self._http), self.session_id)) async def request( self, @@ -243,9 +243,7 @@ async def request( ) -> httpx.Response: if json is not None and content is not None: raise TypeError("Passing both `json` and `content` is not supported") - q: dict[str, object] = {"url": url} - if params: - q.update(dict(params)) + q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} opts = FinalRequestOptions.construct( method=method.upper(), url="/curl/raw", @@ -255,7 +253,7 @@ async def request( json_data=json, timeout=timeout, ) - return await self._metro.request(httpx.Response, opts) + return await self._http.request(httpx.Response, opts) @asynccontextmanager async def stream( @@ -268,19 +266,18 @@ async def stream( params: Mapping[str, object] | None = None, timeout: float | Timeout | None | NotGiven = not_given, ) -> AsyncIterator[httpx.Response]: - q: dict[str, Any] = dict(self._metro.default_query) + q: dict[str, Any] = dict(self._http.default_query) + q.update(sanitize_curl_raw_params(params)) q["url"] = url - if params: - q.update(dict(params)) - h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)} + h = {k: v for k, v in self._http.default_headers.items() if isinstance(v, str)} if content is None: h.pop("Content-Type", None) if headers: h.update(headers) - eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout - async with self._metro._client.stream( + eff_timeout = self._http.timeout if isinstance(timeout, NotGiven) else timeout + async with self._http._client.stream( method.upper(), - self._metro._prepare_url("/curl/raw"), + self._http._prepare_url("/curl/raw"), params=q, headers=h, content=content, @@ -291,21 +288,21 @@ async def stream( def browser_scoped_from_browser(parent: Kernel, browser: Any) -> BrowserScopedClient: session_id = session_id_from_browser_like(browser) - metro = base_url_from_browser_like(browser) - if not metro: + session_base = base_url_from_browser_like(browser) + if not session_base: raise ValueError("browser.base_url is required for a browser-scoped client") jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) if not jwt: - raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests") - return BrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt) + raise ValueError("could not parse jwt from browser.cdp_ws_url; required for browser session HTTP") + return BrowserScopedClient(parent, session_id=session_id, session_base_url=session_base, jwt=jwt) def async_browser_scoped_from_browser(parent: AsyncKernel, browser: Any) -> AsyncBrowserScopedClient: session_id = session_id_from_browser_like(browser) - metro = base_url_from_browser_like(browser) - if not metro: + session_base = base_url_from_browser_like(browser) + if not session_base: raise ValueError("browser.base_url is required for a browser-scoped client") jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) if not jwt: - raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests") - return AsyncBrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt) + raise ValueError("could not parse jwt from browser.cdp_ws_url; required for browser session HTTP") + return AsyncBrowserScopedClient(parent, session_id=session_id, session_base_url=session_base, jwt=jwt) diff --git a/src/kernel/lib/browser_scoped/util.py b/src/kernel/lib/browser_scoped/util.py index 5063a417..4a0cec0f 100644 --- a/src/kernel/lib/browser_scoped/util.py +++ b/src/kernel/lib/browser_scoped/util.py @@ -3,6 +3,16 @@ from typing import Any, Mapping from urllib.parse import parse_qs, urlparse +# Query keys reserved for /curl/raw; user-supplied `params` must not override these. +CURL_RAW_RESERVED_QUERY_KEYS: frozenset[str] = frozenset({"url", "jwt"}) + + +def sanitize_curl_raw_params(params: Mapping[str, object] | None) -> dict[str, object]: + """Drop reserved keys from user params so they cannot override the target URL or auth.""" + if not params: + return {} + return {k: v for k, v in dict(params).items() if k not in CURL_RAW_RESERVED_QUERY_KEYS} + def jwt_from_cdp_ws_url(cdp_ws_url: str) -> str | None: parsed = urlparse(cdp_ws_url) diff --git a/src/kernel/types/browser_create_response.py b/src/kernel/types/browser_create_response.py index 9356bb05..a793eb2f 100644 --- a/src/kernel/types/browser_create_response.py +++ b/src/kernel/types/browser_create_response.py @@ -36,7 +36,7 @@ class BrowserCreateResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """Metro-API HTTP base URL for this browser session.""" + """HTTP base URL for this browser session (browser VM / session proxy).""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_list_response.py b/src/kernel/types/browser_list_response.py index f3a88f29..43e60cd1 100644 --- a/src/kernel/types/browser_list_response.py +++ b/src/kernel/types/browser_list_response.py @@ -36,7 +36,7 @@ class BrowserListResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """Metro-API HTTP base URL for this browser session.""" + """HTTP base URL for this browser session (browser VM / session proxy).""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_pool_acquire_response.py b/src/kernel/types/browser_pool_acquire_response.py index 064c405d..ea37ba65 100644 --- a/src/kernel/types/browser_pool_acquire_response.py +++ b/src/kernel/types/browser_pool_acquire_response.py @@ -36,7 +36,7 @@ class BrowserPoolAcquireResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """Metro-API HTTP base URL for this browser session.""" + """HTTP base URL for this browser session (browser VM / session proxy).""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_retrieve_response.py b/src/kernel/types/browser_retrieve_response.py index 5b5a8913..c56d159a 100644 --- a/src/kernel/types/browser_retrieve_response.py +++ b/src/kernel/types/browser_retrieve_response.py @@ -36,7 +36,7 @@ class BrowserRetrieveResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """Metro-API HTTP base URL for this browser session.""" + """HTTP base URL for this browser session (browser VM / session proxy).""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_update_response.py b/src/kernel/types/browser_update_response.py index 188895ad..325f8f1f 100644 --- a/src/kernel/types/browser_update_response.py +++ b/src/kernel/types/browser_update_response.py @@ -36,7 +36,7 @@ class BrowserUpdateResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """Metro-API HTTP base URL for this browser session.""" + """HTTP base URL for this browser session (browser VM / session proxy).""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/invocation_list_browsers_response.py b/src/kernel/types/invocation_list_browsers_response.py index 23eda779..e99b5087 100644 --- a/src/kernel/types/invocation_list_browsers_response.py +++ b/src/kernel/types/invocation_list_browsers_response.py @@ -36,7 +36,7 @@ class Browser(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """Metro-API HTTP base URL for this browser session.""" + """HTTP base URL for this browser session (browser VM / session proxy).""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/tests/test_browser_scoped.py b/tests/test_browser_scoped.py index 5cdd8911..2475a961 100644 --- a/tests/test_browser_scoped.py +++ b/tests/test_browser_scoped.py @@ -17,8 +17,8 @@ def _fake_browser() -> dict[str, str]: return { "session_id": "sess-1", - "base_url": "http://metro.test/browser/kernel", - "cdp_ws_url": "wss://metro.test/browser/cdp?jwt=token-abc", + "base_url": "http://browser-session.test/browser/kernel", + "cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=token-abc", "webdriver_ws_url": "wss://x", "created_at": "2020-01-01T00:00:00Z", "headless": True, @@ -32,8 +32,8 @@ def test_jwt_from_cdp_ws_url() -> None: @respx.mock -def test_for_browser_process_exec_routes_to_metro() -> None: - metro = respx.post("http://metro.test/browser/kernel/process/exec?jwt=token-abc").mock( +def test_for_browser_process_exec_routes_to_session_base() -> None: + route = respx.post("http://browser-session.test/browser/kernel/process/exec?jwt=token-abc").mock( return_value=httpx.Response( 200, json={ @@ -46,8 +46,8 @@ def test_for_browser_process_exec_routes_to_metro() -> None: with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: b = client.for_browser(_fake_browser()) out = b.process.exec(command="echo", args=["hi"]) - assert metro.called - sent = metro.calls[0].request.read().decode() + assert route.called + sent = route.calls[0].request.read().decode() body = json.loads(sent) assert body["command"] == "echo" assert body["args"] == ["hi"] @@ -56,7 +56,9 @@ def test_for_browser_process_exec_routes_to_metro() -> None: @respx.mock def test_browser_request_uses_curl_raw() -> None: - route = respx.get("http://metro.test/browser/kernel/curl/raw").mock(return_value=httpx.Response(200, content=b"ok")) + route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( + return_value=httpx.Response(200, content=b"ok") + ) with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: b = client.for_browser(_fake_browser()) r = b.request("GET", "https://example.com", params={"timeout_ms": 5000}) @@ -67,9 +69,50 @@ def test_browser_request_uses_curl_raw() -> None: assert "jwt=token-abc" in str(route.calls[0].request.url) +@respx.mock +def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: + route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( + return_value=httpx.Response(200, content=b"ok") + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + b = client.for_browser(_fake_browser()) + b.request( + "GET", + "https://example.com", + params={"url": "https://evil.example", "jwt": "other", "timeout_ms": 1}, + ) + assert route.called + req_url = route.calls[0].request.url + assert str(req_url.params.get("url")) == "https://example.com" + assert str(req_url.params.get("jwt")) == "token-abc" + assert str(req_url.params.get("timeout_ms")) == "1" + + +@respx.mock +def test_browser_stream_params_cannot_override_target_url_or_jwt() -> None: + route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( + return_value=httpx.Response(200, content=b"streamed") + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + b = client.for_browser(_fake_browser()) + with b.stream( + "GET", + "https://example.com", + params={"url": "https://evil.example", "jwt": "other"}, + ) as resp: + assert resp.status_code == 200 + assert resp.read() == b"streamed" + assert route.called + req_url = route.calls[0].request.url + assert str(req_url.params.get("url")) == "https://example.com" + assert str(req_url.params.get("jwt")) == "token-abc" + + @respx.mock def test_browser_stream_reads_body() -> None: - respx.get("http://metro.test/browser/kernel/curl/raw").mock(return_value=httpx.Response(200, content=b"streamed")) + respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( + return_value=httpx.Response(200, content=b"streamed") + ) with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: b = client.for_browser(_fake_browser()) with b.stream("GET", "https://example.com") as resp: From cfff5b4c3635d327dd1ac0779d4e17e395efbec0 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:19:45 -0400 Subject: [PATCH 03/17] fix: type-check browser-scoped helpers Keep the browser-scoped request helpers aligned with repo linting and reserve internal raw-request query keys without exposing implementation details. Made-with: Cursor --- src/kernel/lib/browser_scoped/client.py | 38 ++++++++++++++++++------- src/kernel/lib/browser_scoped/util.py | 11 ++++--- tests/test_browser_scoped.py | 19 ++++++++----- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/kernel/lib/browser_scoped/client.py b/src/kernel/lib/browser_scoped/client.py index 99b3ed2d..0dcd91d1 100644 --- a/src/kernel/lib/browser_scoped/client.py +++ b/src/kernel/lib/browser_scoped/client.py @@ -133,10 +133,10 @@ def request( method=method.upper(), url="/curl/raw", params=q, - headers=headers if headers is not None else not_given, - content=content, + headers=_normalize_headers(headers), + content=_normalize_binary_content(content), json_data=json, - timeout=timeout, + timeout=_normalize_timeout(timeout), ) return self._http.request(httpx.Response, opts) @@ -165,8 +165,8 @@ def stream( self._http._prepare_url("/curl/raw"), params=q, headers=h, - content=content, - timeout=eff_timeout, + content=_normalize_binary_content(content), + timeout=_normalize_timeout(eff_timeout), ) with cm as resp: yield resp @@ -248,10 +248,10 @@ async def request( method=method.upper(), url="/curl/raw", params=q, - headers=headers if headers is not None else not_given, - content=content, + headers=_normalize_headers(headers), + content=_normalize_binary_content(content), json_data=json, - timeout=timeout, + timeout=_normalize_timeout(timeout), ) return await self._http.request(httpx.Response, opts) @@ -280,8 +280,8 @@ async def stream( self._http._prepare_url("/curl/raw"), params=q, headers=h, - content=content, - timeout=eff_timeout, + content=_normalize_binary_content(content), + timeout=_normalize_timeout(eff_timeout), ) as resp: yield resp @@ -306,3 +306,21 @@ def async_browser_scoped_from_browser(parent: AsyncKernel, browser: Any) -> Asyn if not jwt: raise ValueError("could not parse jwt from browser.cdp_ws_url; required for browser session HTTP") return AsyncBrowserScopedClient(parent, session_id=session_id, session_base_url=session_base, jwt=jwt) + + +def _normalize_headers(headers: Mapping[str, str] | None) -> Mapping[str, str]: + return headers if headers is not None else {} + + +def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Timeout | None: + return None if isinstance(timeout, NotGiven) else timeout + + +def _normalize_binary_content(content: BinaryTypes | None) -> httpx._types.RequestContent | None: + if content is None: + return None + if isinstance(content, bytearray): + return bytes(content) + if isinstance(content, memoryview): + return content.tobytes() + return cast(httpx._types.RequestContent, content) diff --git a/src/kernel/lib/browser_scoped/util.py b/src/kernel/lib/browser_scoped/util.py index 4a0cec0f..9be49245 100644 --- a/src/kernel/lib/browser_scoped/util.py +++ b/src/kernel/lib/browser_scoped/util.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Mapping +from typing import Any, Mapping, cast from urllib.parse import parse_qs, urlparse # Query keys reserved for /curl/raw; user-supplied `params` must not override these. @@ -27,7 +27,8 @@ def session_id_from_browser_like(browser: Any) -> str: if isinstance(sid, str) and sid: return sid if isinstance(browser, Mapping): - m = browser.get("session_id") + mapping = cast(Mapping[str, object], browser) + m = mapping.get("session_id") if isinstance(m, str) and m: return m raise TypeError("browser object must have a non-empty session_id") @@ -38,7 +39,8 @@ def base_url_from_browser_like(browser: Any) -> str | None: if isinstance(bu, str) and bu.strip(): return bu.strip().rstrip("/") + "/" if isinstance(browser, Mapping): - raw = browser.get("base_url") + mapping = cast(Mapping[str, object], browser) + raw = mapping.get("base_url") if isinstance(raw, str) and raw.strip(): return raw.strip().rstrip("/") + "/" return None @@ -49,7 +51,8 @@ def cdp_ws_url_from_browser_like(browser: Any) -> str: if isinstance(u, str) and u: return u if isinstance(browser, Mapping): - m = browser.get("cdp_ws_url") + mapping = cast(Mapping[str, object], browser) + m = mapping.get("cdp_ws_url") if isinstance(m, str) and m: return m raise TypeError("browser object must have a non-empty cdp_ws_url") diff --git a/tests/test_browser_scoped.py b/tests/test_browser_scoped.py index 2475a961..7d5dcb89 100644 --- a/tests/test_browser_scoped.py +++ b/tests/test_browser_scoped.py @@ -1,7 +1,8 @@ from __future__ import annotations -import os import json +import os +from typing import cast import httpx import respx @@ -14,7 +15,7 @@ api_key = "sk-123" -def _fake_browser() -> dict[str, str]: +def _fake_browser() -> dict[str, object]: return { "session_id": "sess-1", "base_url": "http://browser-session.test/browser/kernel", @@ -47,7 +48,8 @@ def test_for_browser_process_exec_routes_to_session_base() -> None: b = client.for_browser(_fake_browser()) out = b.process.exec(command="echo", args=["hi"]) assert route.called - sent = route.calls[0].request.read().decode() + request = cast(httpx.Request, route.calls[0].request) + sent = request.read().decode() body = json.loads(sent) assert body["command"] == "echo" assert body["args"] == ["hi"] @@ -65,8 +67,9 @@ def test_browser_request_uses_curl_raw() -> None: assert r.status_code == 200 assert r.content == b"ok" assert route.called - assert "curl/raw" in str(route.calls[0].request.url) - assert "jwt=token-abc" in str(route.calls[0].request.url) + request = cast(httpx.Request, route.calls[0].request) + assert "curl/raw" in str(request.url) + assert "jwt=token-abc" in str(request.url) @respx.mock @@ -82,7 +85,8 @@ def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: params={"url": "https://evil.example", "jwt": "other", "timeout_ms": 1}, ) assert route.called - req_url = route.calls[0].request.url + request = cast(httpx.Request, route.calls[0].request) + req_url = request.url assert str(req_url.params.get("url")) == "https://example.com" assert str(req_url.params.get("jwt")) == "token-abc" assert str(req_url.params.get("timeout_ms")) == "1" @@ -103,7 +107,8 @@ def test_browser_stream_params_cannot_override_target_url_or_jwt() -> None: assert resp.status_code == 200 assert resp.read() == b"streamed" assert route.called - req_url = route.calls[0].request.url + request = cast(httpx.Request, route.calls[0].request) + req_url = request.url assert str(req_url.params.get("url")) == "https://example.com" assert str(req_url.params.get("jwt")) == "token-abc" From fc34859c4f60f84038b425d9930c512e58134dea Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:22:12 -0400 Subject: [PATCH 04/17] chore: fix browser-scoped test import order Keep the browser-scoped test file aligned with the repo lint configuration so the follow-up typing fixes pass CI. Made-with: Cursor --- tests/test_browser_scoped.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_browser_scoped.py b/tests/test_browser_scoped.py index 7d5dcb89..1cbe0616 100644 --- a/tests/test_browser_scoped.py +++ b/tests/test_browser_scoped.py @@ -1,7 +1,7 @@ from __future__ import annotations -import json import os +import json from typing import cast import httpx From 8e8dde241c8817944baaacd155fe196f200868e8 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:26:52 -0400 Subject: [PATCH 05/17] fix: satisfy browser-scoped lint checks Tighten browser-scoped helper typing and test casts so the Python SDK passes the repository's lint and pyright checks cleanly. Made-with: Cursor --- .../browser_scoped/browser_session_kernel.py | 29 ++++++++++++------- src/kernel/lib/browser_scoped/client.py | 8 ++--- tests/test_browser_scoped.py | 14 +++++---- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/kernel/lib/browser_scoped/browser_session_kernel.py b/src/kernel/lib/browser_scoped/browser_session_kernel.py index 6be815bd..55e25d76 100644 --- a/src/kernel/lib/browser_scoped/browser_session_kernel.py +++ b/src/kernel/lib/browser_scoped/browser_session_kernel.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, cast +from typing import Any, Mapping, cast +from typing_extensions import override from ..._client import Kernel, AsyncKernel from ..._compat import model_copy @@ -18,11 +19,10 @@ def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None: self._scoped_session_id = browser_session_id super().__init__(**kwargs) + @override def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: options = super()._prepare_options(options) url = options.url - if not isinstance(url, str): - return options prefix = f"/browsers/{self._scoped_session_id}/" if not url.startswith(prefix): return options @@ -30,7 +30,7 @@ def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: new_url = f"/{suffix}" if suffix else "/" out = model_copy(options) out.url = new_url - return cast(FinalRequestOptions, out) + return out class _BrowserSessionAsyncKernel(AsyncKernel): @@ -40,11 +40,10 @@ def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None: self._scoped_session_id = browser_session_id super().__init__(**kwargs) + @override async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: options = await super()._prepare_options(options) url = options.url - if not isinstance(url, str): - return options prefix = f"/browsers/{self._scoped_session_id}/" if not url.startswith(prefix): return options @@ -52,15 +51,19 @@ async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOp new_url = f"/{suffix}" if suffix else "/" out = model_copy(options) out.url = new_url - return cast(FinalRequestOptions, out) + return out def build_browser_session_kernel( parent: Kernel, *, session_id: str, session_base_url: str, jwt: str ) -> _BrowserSessionKernel: """Build a sync client sharing the parent's httpx transport; requests use session_base_url.""" - base_q = getattr(parent, "_custom_query", None) or {} - dq = {str(k): v for k, v in dict(base_q).items()} + base_q_raw = getattr(parent, "_custom_query", None) + if isinstance(base_q_raw, Mapping): + base_q = {str(k): v for k, v in cast(Mapping[str, object], base_q_raw).items()} + else: + base_q = {} + dq = dict(base_q) dq["jwt"] = jwt return _BrowserSessionKernel( browser_session_id=session_id, @@ -78,8 +81,12 @@ def build_browser_session_kernel( def build_async_browser_session_kernel( parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str ) -> _BrowserSessionAsyncKernel: - base_q = getattr(parent, "_custom_query", None) or {} - dq = {str(k): v for k, v in dict(base_q).items()} + base_q_raw = getattr(parent, "_custom_query", None) + if isinstance(base_q_raw, Mapping): + base_q = {str(k): v for k, v in cast(Mapping[str, object], base_q_raw).items()} + else: + base_q = {} + dq = dict(base_q) dq["jwt"] = jwt return _BrowserSessionAsyncKernel( browser_session_id=session_id, diff --git a/src/kernel/lib/browser_scoped/client.py b/src/kernel/lib/browser_scoped/client.py index 0dcd91d1..a9640d56 100644 --- a/src/kernel/lib/browser_scoped/client.py +++ b/src/kernel/lib/browser_scoped/client.py @@ -3,9 +3,9 @@ from __future__ import annotations import inspect -from typing import TYPE_CHECKING, Any, Mapping, cast +from typing import IO, TYPE_CHECKING, Any, Mapping, cast from contextlib import contextmanager, asynccontextmanager -from collections.abc import Iterator, AsyncIterator +from collections.abc import Iterable, Iterator, AsyncIterator import httpx @@ -316,11 +316,11 @@ def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Ti return None if isinstance(timeout, NotGiven) else timeout -def _normalize_binary_content(content: BinaryTypes | None) -> httpx._types.RequestContent | None: +def _normalize_binary_content(content: BinaryTypes | None) -> bytes | IO[bytes] | Iterable[bytes] | None: if content is None: return None if isinstance(content, bytearray): return bytes(content) if isinstance(content, memoryview): return content.tobytes() - return cast(httpx._types.RequestContent, content) + return content diff --git a/tests/test_browser_scoped.py b/tests/test_browser_scoped.py index 1cbe0616..5bbdafb5 100644 --- a/tests/test_browser_scoped.py +++ b/tests/test_browser_scoped.py @@ -2,7 +2,7 @@ import os import json -from typing import cast +from typing import Any, cast import httpx import respx @@ -48,7 +48,8 @@ def test_for_browser_process_exec_routes_to_session_base() -> None: b = client.for_browser(_fake_browser()) out = b.process.exec(command="echo", args=["hi"]) assert route.called - request = cast(httpx.Request, route.calls[0].request) + call = cast(Any, route.calls[0]) + request = cast(httpx.Request, call.request) sent = request.read().decode() body = json.loads(sent) assert body["command"] == "echo" @@ -67,7 +68,8 @@ def test_browser_request_uses_curl_raw() -> None: assert r.status_code == 200 assert r.content == b"ok" assert route.called - request = cast(httpx.Request, route.calls[0].request) + call = cast(Any, route.calls[0]) + request = cast(httpx.Request, call.request) assert "curl/raw" in str(request.url) assert "jwt=token-abc" in str(request.url) @@ -85,7 +87,8 @@ def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: params={"url": "https://evil.example", "jwt": "other", "timeout_ms": 1}, ) assert route.called - request = cast(httpx.Request, route.calls[0].request) + call = cast(Any, route.calls[0]) + request = cast(httpx.Request, call.request) req_url = request.url assert str(req_url.params.get("url")) == "https://example.com" assert str(req_url.params.get("jwt")) == "token-abc" @@ -107,7 +110,8 @@ def test_browser_stream_params_cannot_override_target_url_or_jwt() -> None: assert resp.status_code == 200 assert resp.read() == b"streamed" assert route.called - request = cast(httpx.Request, route.calls[0].request) + call = cast(Any, route.calls[0]) + request = cast(httpx.Request, call.request) req_url = request.url assert str(req_url.params.get("url")) == "https://example.com" assert str(req_url.params.get("jwt")) == "token-abc" From 53b17c8241cc71261d1e96f5929cbd4f05b2064b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:27:02 -0400 Subject: [PATCH 06/17] feat: generate browser-scoped resource bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the handwritten Python browser-scoped façade with deterministic generated bindings from the browser resource graph, and enforce regeneration during lint. Made-with: Cursor --- scripts/generate_browser_scoped.py | 649 ++++++ scripts/lint | 7 + src/kernel/lib/browser_scoped/client.py | 118 +- .../lib/browser_scoped/generated_bindings.py | 1945 +++++++++++++++++ src/kernel/lib/browser_scoped/util.py | 29 + 5 files changed, 2634 insertions(+), 114 deletions(-) create mode 100644 scripts/generate_browser_scoped.py create mode 100644 src/kernel/lib/browser_scoped/generated_bindings.py diff --git a/scripts/generate_browser_scoped.py b/scripts/generate_browser_scoped.py new file mode 100644 index 00000000..b682dbc6 --- /dev/null +++ b/scripts/generate_browser_scoped.py @@ -0,0 +1,649 @@ +#!/usr/bin/env python3 +"""Generate browser-scoped binding classes from AST of src/kernel/resources/browsers/**.""" + +from __future__ import annotations + +import ast +from pathlib import Path +from dataclasses import dataclass + + +@dataclass(frozen=True) +class IdBinding: + kind: str # "positional" | "kwonly" + + +def _repo_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def _browsers_root() -> Path: + return _repo_root() / "src/kernel/resources/browsers" + + +def _iter_browser_py_files() -> list[Path]: + root = _browsers_root() + out: list[Path] = [] + for p in sorted(root.rglob("*.py")): + if p.name in ("__init__.py", "browsers.py"): + continue + out.append(p) + return out + + +def _is_resource_class(node: ast.ClassDef) -> bool: + if not node.name.endswith("Resource"): + return False + if node.name.startswith("Async"): + return False + if "With" in node.name: + return False + for b in node.bases: + if isinstance(b, ast.Name) and b.id == "SyncAPIResource": + return True + if isinstance(b, ast.Attribute) and b.attr == "SyncAPIResource": + return True + return False + + +def _is_async_resource_class(node: ast.ClassDef) -> bool: + if not node.name.startswith("Async") or not node.name.endswith("Resource"): + return False + if "With" in node.name: + return False + for b in node.bases: + if isinstance(b, ast.Name) and b.id == "AsyncAPIResource": + return True + if isinstance(b, ast.Attribute) and b.attr == "AsyncAPIResource": + return True + return False + + +def _async_resource_name(sync_name: str) -> str: + if sync_name.startswith("Async"): + return sync_name + return f"Async{sync_name}" + + +def _has_cached_property_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + for d in node.decorator_list: + if isinstance(d, ast.Name) and d.id == "cached_property": + return True + if isinstance(d, ast.Attribute) and d.attr == "cached_property": + return True + return False + + +def _annotation_root_name(node: ast.AST | None) -> str | None: + if node is None: + return None + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Subscript): + return _annotation_root_name(node.value) + if isinstance(node, ast.Attribute): + return node.attr + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _annotation_root_name(node.left) or _annotation_root_name(node.right) + return None + + +def _find_id_binding(arguments: ast.arguments) -> IdBinding | None: + pos = list(arguments.posonlyargs) + list(arguments.args) + if len(pos) > 0 and pos[0].arg == "self": + rest = pos[1:] + else: + rest = pos + for a in rest: + if a.arg == "id": + return IdBinding("positional") + for a in arguments.kwonlyargs: + if a.arg == "id": + return IdBinding("kwonly") + return None + + +def _strip_id_from_arguments(arguments: ast.arguments) -> ast.arguments: + """Remove `id` from positional and keyword-only parameters; fix defaults tail.""" + + posonly = list(arguments.posonlyargs) + pos = list(arguments.args) + combined = posonly + pos + defaults = list(arguments.defaults or []) + nd = len(defaults) + + kept: list[tuple[ast.arg, ast.expr | None, str]] = [] + for i, a in enumerate(combined): + if a.arg == "id": + continue + d: ast.expr | None = None + if nd and i >= len(combined) - nd: + d = defaults[i - (len(combined) - nd)] + kind = "posonly" if i < len(posonly) else "pos" + kept.append((a, d, kind)) + + new_posonly = [a for a, _d, k in kept if k == "posonly"] + new_pos = [a for a, _d, k in kept if k == "pos"] + new_combined = new_posonly + new_pos + new_defaults_list = [d for a, d, k in kept if d is not None] + new_nd = len(new_defaults_list) + if new_nd > len(new_combined): + raise RuntimeError("invalid defaults after strip") + new_defaults = new_defaults_list[-new_nd:] if new_nd else [] + + kwonly = [a for a in arguments.kwonlyargs if a.arg != "id"] + kw_defaults_old = list(arguments.kw_defaults or []) + new_kw_defaults: list[ast.expr | None] = [] + for i, a in enumerate(arguments.kwonlyargs): + d = kw_defaults_old[i] if i < len(kw_defaults_old) else None + if a.arg != "id": + new_kw_defaults.append(d) + + return ast.arguments( + posonlyargs=new_posonly, + args=new_pos, + kwonlyargs=kwonly, + kw_defaults=new_kw_defaults, + defaults=new_defaults, + vararg=arguments.vararg, + kwarg=arguments.kwarg, + ) + + +def _public_signature(inner: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.arguments: + if _find_id_binding(inner.args) is None: + return inner.args + return _strip_id_from_arguments(inner.args) + + +def _without_leading_self(arguments: ast.arguments) -> ast.arguments: + """Drop `self` from positional args for use in subclass method signatures (posonly unused here).""" + + if arguments.posonlyargs: + raise RuntimeError("positional-only parameters are not supported for browser binding generation") + args = list(arguments.args) + defaults = list(arguments.defaults or []) + if not args or args[0].arg != "self": + raise RuntimeError("expected leading self parameter") + new_args = args[1:] + n_old = len(args) + n_new = len(new_args) + nd = len(defaults) + if nd: + if nd > n_old: + raise RuntimeError("too many defaults") + new_defaults = defaults[-min(nd, n_new) :] if n_new else [] + else: + new_defaults = [] + return ast.arguments( + posonlyargs=[], + args=new_args, + kwonlyargs=list(arguments.kwonlyargs), + kw_defaults=list(arguments.kw_defaults or []), + defaults=new_defaults, + vararg=arguments.vararg, + kwarg=arguments.kwarg, + ) + + +def _emit_call_forward(inner_name: str, inner: ast.FunctionDef | ast.AsyncFunctionDef) -> str: + binding = _find_id_binding(inner.args) + pos_all = list(inner.args.posonlyargs) + list(inner.args.args) + if not pos_all or pos_all[0].arg != "self": + raise RuntimeError(f"expected self first on {inner_name}") + rest_pos = pos_all[1:] + + pos_call: list[str] = [] + for a in rest_pos: + if a.arg == "id": + pos_call.append("self._session_id") + else: + pos_call.append(a.arg) + + kw_parts: list[str] = [] + for a in inner.args.kwonlyargs: + if a.arg == "id": + kw_parts.append("id=self._session_id") + else: + kw_parts.append(f"{a.arg}={a.arg}") + + if inner.args.vararg is not None or inner.args.kwarg is not None: + raise RuntimeError(f"unsupported vararg/kwarg on {inner_name}") + + if binding is None: + inner_pos = ", ".join(a.arg for a in rest_pos) + inner_kw = ", ".join(f"{a.arg}={a.arg}" for a in inner.args.kwonlyargs) + bits = [inner_pos] if inner_pos else [] + if inner_kw: + bits.append(inner_kw) + return f"self._inner.{inner_name}({', '.join(bits)})" + + return f"self._inner.{inner_name}({', '.join([*pos_call, *kw_parts])})" + + +def _emit_method( + inner: ast.FunctionDef | ast.AsyncFunctionDef, + *, + is_async: bool, +) -> str | None: + if inner.name.startswith("_"): + return None + if _has_cached_property_decorator(inner): + return None + + binding = _find_id_binding(inner.args) + if binding is None: + return None + + pub_args = _without_leading_self(_public_signature(inner)) + ret = inner.returns + ret_s = "" if ret is None else f" -> {ast.unparse(ret)}" + prefix = "async def" if is_async else "def" + await_kw = "await " if is_async else "" + body = f"return {await_kw}{_emit_call_forward(inner.name, inner)}" + + args_s = ast.unparse(pub_args) + if args_s.startswith("(") and args_s.endswith(")"): + inner_args = args_s[1:-1].strip() + else: + inner_args = args_s.strip() + if inner_args: + sig_inner = f"self, {inner_args}" + else: + sig_inner = "self" + + lines = [f" {prefix} {inner.name}({sig_inner}){ret_s}:", f" {body}"] + return "\n".join(lines) + + +def _bound_class_name(sync_cls: str) -> str: + return f"Bound{sync_cls}" + + +def _import_line_for_class(file_path: Path, class_name: str) -> str: + rel = file_path.relative_to(_repo_root() / "src/kernel") + mod = ".".join(rel.with_suffix("").parts) + return f"from ...{mod} import {class_name}" + + +def _discover_nested_subresources(sync_class: ast.ClassDef) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for node in sync_class.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if not _has_cached_property_decorator(node): + continue + if node.name.startswith("with_"): + continue + root = _annotation_root_name(node.returns) + if root is None: + continue + if not root.endswith("Resource") or root.startswith("Async"): + continue + if "With" in root: + continue + out.append((node.name, root)) + return out + + +def _collect_sync_resource_classes(tree: ast.Module) -> dict[str, ast.ClassDef]: + out: dict[str, ast.ClassDef] = {} + for node in tree.body: + if isinstance(node, ast.ClassDef) and _is_resource_class(node): + out[node.name] = node + return out + + +def _collect_async_resource_classes(tree: ast.Module) -> dict[str, ast.ClassDef]: + out: dict[str, ast.ClassDef] = {} + for node in tree.body: + if isinstance(node, ast.ClassDef) and _is_async_resource_class(node): + out[node.name] = node + return out + + +def _emit_bound_class_pair( + sync_name: str, + sync_cls: ast.ClassDef, + async_cls: ast.ClassDef | None, + nested: dict[str, list[tuple[str, str]]], +) -> str: + bound = _bound_class_name(sync_name) + lines: list[str] = [ + f"class {bound}(ScopedResourceProxy):", + ' """Session id is injected for browser API methods."""', + ] + + for prop_name, inner_cls in nested.get(sync_name, []): + ib = _bound_class_name(inner_cls) + imp = _import_line_for_class(_class_file(inner_cls), inner_cls) + lines.append(" @cached_property") + lines.append(f" def {prop_name}(self) -> {ib}:") + lines.append(f" {imp}") + lines.append(f" return {ib}({inner_cls}(self._inner._client), self._session_id)") + lines.append("") + + for node in sync_cls.body: + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"): + chunk = _emit_method(node, is_async=False) + if chunk: + lines.append(chunk) + lines.append("") + + if async_cls is not None: + an = _async_resource_name(sync_name) + bound_a = _bound_class_name(an) + lines.append("") + lines.append(f"class {bound_a}(ScopedResourceProxy):") + lines.append(' """Async variant: session id is injected for browser API methods."""') + + for prop_name, inner_cls in nested.get(sync_name, []): + ainner = _async_resource_name(inner_cls) + ib = _bound_class_name(ainner) + imp = _import_line_for_class(_class_file(inner_cls), ainner) + lines.append(" @cached_property") + lines.append(f" def {prop_name}(self) -> {ib}:") + lines.append(f" {imp}") + lines.append(f" return {ib}({ainner}(self._inner._client), self._session_id)") + lines.append("") + + for node in async_cls.body: + if isinstance(node, ast.AsyncFunctionDef) and not node.name.startswith("_"): + chunk = _emit_method(node, is_async=True) + if chunk: + lines.append(chunk) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +_class_file_cache: dict[str, Path] = {} + + +def _index_classes_by_name() -> None: + global _class_file_cache + _class_file_cache = {} + for path in _iter_browser_py_files(): + tree = ast.parse(path.read_text(encoding="utf-8")) + for name in _collect_sync_resource_classes(tree): + _class_file_cache[name] = path + for name in _collect_async_resource_classes(tree): + _class_file_cache[name] = path + + +def _class_file(class_name: str) -> Path: + return _class_file_cache[class_name] + + +def _nested_map() -> dict[str, list[tuple[str, str]]]: + nested: dict[str, list[tuple[str, str]]] = {} + for path in _iter_browser_py_files(): + tree = ast.parse(path.read_text(encoding="utf-8")) + for name, cls in _collect_sync_resource_classes(tree).items(): + pairs = _discover_nested_subresources(cls) + if pairs: + nested[name] = pairs + return nested + + +def _browsers_py_path() -> Path: + return _browsers_root() / "browsers.py" + + +def _cached_property_resource_subresources(cls: ast.ClassDef) -> dict[str, str]: + """prop_name -> sync Resource class name for @cached_property -> XResource style members.""" + + out: dict[str, str] = {} + for node in cls.body: + if not isinstance(node, ast.FunctionDef): + continue + if not _has_cached_property_decorator(node): + continue + if node.name.startswith("with_"): + continue + root = _annotation_root_name(node.returns) + if root is None: + continue + if "With" in root or not root.endswith("Resource"): + continue + if root.startswith("Async"): + continue + out[node.name] = root + return out + + +def _facade_entries_from_browsers_py() -> list[tuple[str, str]]: + """Top-level browser subresources from `BrowsersResource` / `AsyncBrowsersResource` (AST).""" + + path = _browsers_py_path() + tree = ast.parse(path.read_text(encoding="utf-8")) + sync_cls: ast.ClassDef | None = None + async_cls: ast.ClassDef | None = None + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == "BrowsersResource": + sync_cls = node + elif isinstance(node, ast.ClassDef) and node.name == "AsyncBrowsersResource": + async_cls = node + if sync_cls is None or async_cls is None: + raise RuntimeError(f"expected BrowsersResource and AsyncBrowsersResource in {path}") + + sync_map = _cached_property_resource_subresources(sync_cls) + async_map: dict[str, str] = {} + for node in async_cls.body: + if not isinstance(node, ast.FunctionDef): + continue + if not _has_cached_property_decorator(node): + continue + if node.name.startswith("with_"): + continue + root = _annotation_root_name(node.returns) + if root is None or "With" in root: + continue + if not (root.startswith("Async") and root.endswith("Resource")): + continue + async_map[node.name] = root + + if set(sync_map) != set(async_map): + raise RuntimeError( + "BrowsersResource vs AsyncBrowsersResource cached_property session resources mismatch: " + f"sync={sorted(sync_map)!r} async={sorted(async_map)!r}" + ) + + for prop in sorted(sync_map): + expected = _async_resource_name(sync_map[prop]) + got = async_map[prop] + if got != expected: + raise RuntimeError(f"{path}: property {prop!r}: expected async return {expected!r}, got {got!r}") + + return sorted(sync_map.items(), key=lambda t: t[0]) + + +def _emit_facade_mixins(entries: list[tuple[str, str]]) -> str: + lines: list[str] = [ + "class BrowserScopedFacadeMixin:", + ' """Top-level browser session subresources (sync); uses `_http` and `session_id`."""', + "", + " _http: Any", + " session_id: str", + "", + ] + for prop, sync_cls in entries: + bound = _bound_class_name(sync_cls) + imp = _import_line_for_class(_class_file(sync_cls), sync_cls) + lines.append(" @cached_property") + lines.append(f" def {prop}(self) -> {bound}:") + lines.append(f" {imp}") + lines.append(f" return {bound}({sync_cls}(self._http), self.session_id)") + lines.append("") + + lines.extend( + [ + "", + "class AsyncBrowserScopedFacadeMixin:", + ' """Top-level browser session subresources (async); uses `_http` and `session_id`."""', + "", + " _http: Any", + " session_id: str", + "", + ] + ) + for prop, sync_cls in entries: + async_cls = _async_resource_name(sync_cls) + bound = _bound_class_name(async_cls) + imp = _import_line_for_class(_class_file(sync_cls), async_cls) + lines.append(" @cached_property") + lines.append(f" def {prop}(self) -> {bound}:") + lines.append(f" {imp}") + lines.append(f" return {bound}({async_cls}(self._http), self.session_id)") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def _generation_order(all_sync: list[str], nested: dict[str, list[tuple[str, str]]]) -> list[str]: + deps: dict[str, set[str]] = {c: set() for c in all_sync} + for parent, pairs in nested.items(): + for _, inner in pairs: + deps.setdefault(parent, set()).add(inner) + + ordered: list[str] = [] + remaining = set(all_sync) + while remaining: + ready = sorted([c for c in remaining if not (deps.get(c, set()) & remaining)]) + if not ready: + raise RuntimeError(f"cycle in nested resources: {remaining}") + for c in ready: + ordered.append(c) + remaining.remove(c) + return ordered + + +def _path_to_module(path: Path) -> str: + src = _repo_root() / "src" + rel = path.resolve().relative_to(src) + return ".".join(rel.with_suffix("").parts) + + +def _import_from_to_absolute(module_file: Path, imp: ast.ImportFrom) -> ast.ImportFrom: + level = imp.level or 0 + if level == 0: + return imp + cur = _path_to_module(module_file) + pkg = ".".join(cur.split(".")[:-1]) + if level > 1: + pkg_parts = pkg.split(".") + up = level - 1 + if len(pkg_parts) < up: + raise ValueError(f"cannot resolve import {ast.dump(imp)} from {module_file}") + pkg = ".".join(pkg_parts[:-up]) + if imp.module: + base = f"{pkg}.{imp.module}" + else: + base = pkg + return ast.ImportFrom(module=base, names=imp.names, level=0) + + +def _imports_from_resource_modules(paths: Iterable[Path]) -> list[str]: + """Collect imports from resource modules, rewritten as absolute `kernel.*` paths.""" + + def skip_line(line: str) -> bool: + if "from __future__ import annotations" in line: + return True + if "kernel._resource import" in line: + return True + if "kernel._utils import" in line: + return True + if "kernel._base_client import" in line: + return True + if "kernel._compat import cached_property" in line: + return True + return False + + seen: set[str] = set() + lines: list[str] = [] + for path in sorted({p.resolve() for p in paths}): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.ImportFrom): + node = _import_from_to_absolute(path, node) + line = ast.unparse(node) + elif isinstance(node, ast.Import): + line = ast.unparse(node) + else: + continue + if skip_line(line): + continue + if line not in seen: + seen.add(line) + lines.append(line) + return lines + + +def _emit_module() -> str: + _index_classes_by_name() + nested = _nested_map() + all_sync = sorted(n for n in _class_file_cache if not n.startswith("Async")) + order = _generation_order(all_sync, nested) + + resource_paths = {_class_file_cache[name] for name in all_sync} + import_lines = _imports_from_resource_modules(resource_paths) + + parts: list[str] = [ + "# Code generated by scripts/generate_browser_scoped.py. DO NOT EDIT.", + "# ruff: noqa: I001, F401", + "# pyright: reportUnusedImport=false", + '"""Browser-scoped wrappers over generated `resources.browsers` classes (AST-driven)."""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from ..._compat import cached_property", + "from .util import ScopedResourceProxy", + ] + if import_lines: + parts.append("") + parts.extend(import_lines) + parts.append("") + + for sync_name in order: + path = _class_file_cache[sync_name] + tree = ast.parse(path.read_text(encoding="utf-8")) + sync_cls = _collect_sync_resource_classes(tree)[sync_name] + async_name = _async_resource_name(sync_name) + async_cls = _collect_async_resource_classes(tree).get(async_name) + parts.append(_emit_bound_class_pair(sync_name, sync_cls, async_cls, nested)) + parts.append("") + + facade_entries = _facade_entries_from_browsers_py() + for _prop, sync_cls in facade_entries: + if sync_cls not in _class_file_cache: + raise RuntimeError(f"facade references unknown resource class {sync_cls!r}") + parts.append(_emit_facade_mixins(facade_entries)) + parts.append("") + + export_names: list[str] = [] + for sync_name in sorted(all_sync): + export_names.append(_bound_class_name(sync_name)) + an = _async_resource_name(sync_name) + if an in _class_file_cache and an != sync_name: + export_names.append(_bound_class_name(an)) + + parts.append("__all__ = [") + for n in sorted(set(export_names)): + parts.append(f' "{n}",') + parts.append("]") + parts.append("") + return "\n".join(parts) + + +def main() -> int: + out = _repo_root() / "src/kernel/lib/browser_scoped/generated_bindings.py" + text = _emit_module() + out.write_text(text, encoding="utf-8") + print(f"Wrote {out} ({len(text.splitlines())} lines)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint b/scripts/lint index 7675e607..693344a5 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,6 +4,13 @@ set -e cd "$(dirname "$0")/.." +echo "==> Regenerating browser-scoped bindings" +python3 scripts/generate_browser_scoped.py +rye run ruff format src/kernel/lib/browser_scoped/generated_bindings.py + +echo "==> Verifying generated browser-scoped bindings are committed" +git diff --exit-code -- src/kernel/lib/browser_scoped/generated_bindings.py + if [ "$1" = "--fix" ]; then echo "==> Running lints with --fix" rye run fix:ruff diff --git a/src/kernel/lib/browser_scoped/client.py b/src/kernel/lib/browser_scoped/client.py index a9640d56..8c6b31e2 100644 --- a/src/kernel/lib/browser_scoped/client.py +++ b/src/kernel/lib/browser_scoped/client.py @@ -2,8 +2,7 @@ from __future__ import annotations -import inspect -from typing import IO, TYPE_CHECKING, Any, Mapping, cast +from typing import IO, TYPE_CHECKING, Any, Mapping from contextlib import contextmanager, asynccontextmanager from collections.abc import Iterable, Iterator, AsyncIterator @@ -18,47 +17,14 @@ ) from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given from ..._models import FinalRequestOptions +from .generated_bindings import BrowserScopedFacadeMixin, AsyncBrowserScopedFacadeMixin from .browser_session_kernel import build_browser_session_kernel, build_async_browser_session_kernel if TYPE_CHECKING: from ..._client import Kernel, AsyncKernel - from ...resources.browsers.logs import LogsResource, AsyncLogsResource - from ...resources.browsers.fs.fs import FsResource, AsyncFsResource - from ...resources.browsers.process import ProcessResource, AsyncProcessResource - from ...resources.browsers.replays import ReplaysResource, AsyncReplaysResource - from ...resources.browsers.computer import ComputerResource, AsyncComputerResource - from ...resources.browsers.playwright import PlaywrightResource, AsyncPlaywrightResource -class _BoundBrowserSubresource: - """Delegates to a generated resource while defaulting `id` to the scoped session.""" - - def __init__(self, inner: Any, session_id: str) -> None: - object.__setattr__(self, "_inner", inner) - object.__setattr__(self, "_session_id", session_id) - - def __getattr__(self, name: str) -> Any: - if name.startswith("_"): - raise AttributeError(name) - attr = getattr(self._inner, name) - if name.startswith("with_") or not callable(attr): - return attr - try: - sig = inspect.signature(attr) - except (TypeError, ValueError): - return attr - if "id" not in sig.parameters: - return attr - - def bound(*args: Any, **kwargs: Any) -> Any: - kw = dict(kwargs) - kw["id"] = self._session_id - return attr(*args, **kw) - - return bound - - -class BrowserScopedClient: +class BrowserScopedClient(BrowserScopedFacadeMixin): """Session-scoped API: subresources without repeating session id; HTTP via browser /curl/raw.""" def __init__(self, parent: Kernel, *, session_id: str, session_base_url: str, jwt: str) -> None: @@ -79,42 +45,6 @@ def parent(self) -> Kernel: def base_url(self) -> str: return self._session_base_url - @property - def process(self) -> ProcessResource: - from ...resources.browsers.process import ProcessResource - - return cast(ProcessResource, _BoundBrowserSubresource(ProcessResource(self._http), self.session_id)) - - @property - def computer(self) -> ComputerResource: - from ...resources.browsers.computer import ComputerResource - - return cast(ComputerResource, _BoundBrowserSubresource(ComputerResource(self._http), self.session_id)) - - @property - def fs(self) -> FsResource: - from ...resources.browsers.fs.fs import FsResource - - return cast(FsResource, _BoundBrowserSubresource(FsResource(self._http), self.session_id)) - - @property - def logs(self) -> LogsResource: - from ...resources.browsers.logs import LogsResource - - return cast(LogsResource, _BoundBrowserSubresource(LogsResource(self._http), self.session_id)) - - @property - def playwright(self) -> PlaywrightResource: - from ...resources.browsers.playwright import PlaywrightResource - - return cast(PlaywrightResource, _BoundBrowserSubresource(PlaywrightResource(self._http), self.session_id)) - - @property - def replays(self) -> ReplaysResource: - from ...resources.browsers.replays import ReplaysResource - - return cast(ReplaysResource, _BoundBrowserSubresource(ReplaysResource(self._http), self.session_id)) - def request( self, method: str, @@ -172,7 +102,7 @@ def stream( yield resp -class AsyncBrowserScopedClient: +class AsyncBrowserScopedClient(AsyncBrowserScopedFacadeMixin): def __init__(self, parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str) -> None: self._parent = parent self.session_id = session_id @@ -190,46 +120,6 @@ def parent(self) -> AsyncKernel: def base_url(self) -> str: return self._session_base_url - @property - def process(self) -> AsyncProcessResource: - from ...resources.browsers.process import AsyncProcessResource - - return cast(AsyncProcessResource, _BoundBrowserSubresource(AsyncProcessResource(self._http), self.session_id)) - - @property - def computer(self) -> AsyncComputerResource: - from ...resources.browsers.computer import AsyncComputerResource - - return cast( - AsyncComputerResource, _BoundBrowserSubresource(AsyncComputerResource(self._http), self.session_id) - ) - - @property - def fs(self) -> AsyncFsResource: - from ...resources.browsers.fs.fs import AsyncFsResource - - return cast(AsyncFsResource, _BoundBrowserSubresource(AsyncFsResource(self._http), self.session_id)) - - @property - def logs(self) -> AsyncLogsResource: - from ...resources.browsers.logs import AsyncLogsResource - - return cast(AsyncLogsResource, _BoundBrowserSubresource(AsyncLogsResource(self._http), self.session_id)) - - @property - def playwright(self) -> AsyncPlaywrightResource: - from ...resources.browsers.playwright import AsyncPlaywrightResource - - return cast( - AsyncPlaywrightResource, _BoundBrowserSubresource(AsyncPlaywrightResource(self._http), self.session_id) - ) - - @property - def replays(self) -> AsyncReplaysResource: - from ...resources.browsers.replays import AsyncReplaysResource - - return cast(AsyncReplaysResource, _BoundBrowserSubresource(AsyncReplaysResource(self._http), self.session_id)) - async def request( self, method: str, diff --git a/src/kernel/lib/browser_scoped/generated_bindings.py b/src/kernel/lib/browser_scoped/generated_bindings.py new file mode 100644 index 00000000..b6ac723e --- /dev/null +++ b/src/kernel/lib/browser_scoped/generated_bindings.py @@ -0,0 +1,1945 @@ +# Code generated by scripts/generate_browser_scoped.py. DO NOT EDIT. +# ruff: noqa: I001, F401 +# pyright: reportUnusedImport=false +"""Browser-scoped wrappers over generated `resources.browsers` classes (AST-driven).""" + +from __future__ import annotations + +from typing import Any + +from ..._compat import cached_property +from .util import ScopedResourceProxy + +from typing import Iterable +from typing_extensions import Literal +import httpx +from kernel._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given +from kernel._response import ( + BinaryAPIResponse, + AsyncBinaryAPIResponse, + StreamedBinaryAPIResponse, + AsyncStreamedBinaryAPIResponse, + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + to_custom_raw_response_wrapper, + async_to_streamed_response_wrapper, + to_custom_streamed_response_wrapper, + async_to_custom_raw_response_wrapper, + async_to_custom_streamed_response_wrapper, +) +from kernel.types.browsers import ( + computer_batch_params, + computer_scroll_params, + computer_press_key_params, + computer_type_text_params, + computer_drag_mouse_params, + computer_move_mouse_params, + computer_click_mouse_params, + computer_write_clipboard_params, + computer_capture_screenshot_params, + computer_set_cursor_visibility_params, +) +from kernel.types.browsers.computer_read_clipboard_response import ComputerReadClipboardResponse +from kernel.types.browsers.computer_get_mouse_position_response import ComputerGetMousePositionResponse +from kernel.types.browsers.computer_set_cursor_visibility_response import ComputerSetCursorVisibilityResponse +import os +from typing import Mapping, Iterable, cast +from kernel.resources.browsers.fs.watch import ( + WatchResource, + AsyncWatchResource, + WatchResourceWithRawResponse, + AsyncWatchResourceWithRawResponse, + WatchResourceWithStreamingResponse, + AsyncWatchResourceWithStreamingResponse, +) +from kernel._files import read_file_content, async_read_file_content +from kernel._types import ( + Body, + Omit, + Query, + Headers, + NoneType, + NotGiven, + FileTypes, + BinaryTypes, + FileContent, + AsyncBinaryTypes, + omit, + not_given, +) +from kernel.types.browsers import ( + f_move_params, + f_upload_params, + f_file_info_params, + f_read_file_params, + f_list_files_params, + f_upload_zip_params, + f_write_file_params, + f_delete_file_params, + f_create_directory_params, + f_delete_directory_params, + f_download_dir_zip_params, + f_set_file_permissions_params, +) +from kernel.types.browsers.f_file_info_response import FFileInfoResponse +from kernel.types.browsers.f_list_files_response import FListFilesResponse +from kernel._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given +from kernel._response import ( + to_raw_response_wrapper, + to_streamed_response_wrapper, + async_to_raw_response_wrapper, + async_to_streamed_response_wrapper, +) +from kernel._streaming import Stream, AsyncStream +from kernel.types.browsers.fs import watch_start_params +from kernel.types.browsers.fs.watch_start_response import WatchStartResponse +from kernel.types.browsers.fs.watch_events_response import WatchEventsResponse +from kernel._types import Body, Omit, Query, Headers, NotGiven, omit, not_given +from kernel.types.browsers import log_stream_params +from kernel.types.shared.log_event import LogEvent +from kernel.types.browsers import playwright_execute_params +from kernel.types.browsers.playwright_execute_response import PlaywrightExecuteResponse +from typing import Dict, Optional +from kernel._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given +from kernel.types.browsers import ( + process_exec_params, + process_kill_params, + process_spawn_params, + process_stdin_params, + process_resize_params, +) +from kernel.types.browsers.process_exec_response import ProcessExecResponse +from kernel.types.browsers.process_kill_response import ProcessKillResponse +from kernel.types.browsers.process_spawn_response import ProcessSpawnResponse +from kernel.types.browsers.process_stdin_response import ProcessStdinResponse +from kernel.types.browsers.process_resize_response import ProcessResizeResponse +from kernel.types.browsers.process_status_response import ProcessStatusResponse +from kernel.types.browsers.process_stdout_stream_response import ProcessStdoutStreamResponse +from kernel.types.browsers import replay_start_params +from kernel.types.browsers.replay_list_response import ReplayListResponse +from kernel.types.browsers.replay_start_response import ReplayStartResponse + + +class BoundComputerResource(ScopedResourceProxy): + """Session id is injected for browser API methods.""" + + def batch( + self, + *, + actions: Iterable[computer_batch_params.Action], + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.batch( + self._session_id, + actions=actions, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def capture_screenshot( + self, + *, + region: computer_capture_screenshot_params.Region | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BinaryAPIResponse: + return self._inner.capture_screenshot( + self._session_id, + region=region, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def click_mouse( + self, + *, + x: int, + y: int, + button: Literal["left", "right", "middle", "back", "forward"] | Omit = omit, + click_type: Literal["down", "up", "click"] | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + num_clicks: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.click_mouse( + self._session_id, + x=x, + y=y, + button=button, + click_type=click_type, + hold_keys=hold_keys, + num_clicks=num_clicks, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def drag_mouse( + self, + *, + path: Iterable[Iterable[int]], + button: Literal["left", "middle", "right"] | Omit = omit, + delay: int | Omit = omit, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: bool | Omit = omit, + step_delay_ms: int | Omit = omit, + steps_per_segment: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.drag_mouse( + self._session_id, + path=path, + button=button, + delay=delay, + duration_ms=duration_ms, + hold_keys=hold_keys, + smooth=smooth, + step_delay_ms=step_delay_ms, + steps_per_segment=steps_per_segment, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def get_mouse_position( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ComputerGetMousePositionResponse: + return self._inner.get_mouse_position( + self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def move_mouse( + self, + *, + x: int, + y: int, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.move_mouse( + self._session_id, + x=x, + y=y, + duration_ms=duration_ms, + hold_keys=hold_keys, + smooth=smooth, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def press_key( + self, + *, + keys: SequenceNotStr[str], + duration: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.press_key( + self._session_id, + keys=keys, + duration=duration, + hold_keys=hold_keys, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def read_clipboard( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ComputerReadClipboardResponse: + return self._inner.read_clipboard( + self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def scroll( + self, + *, + x: int, + y: int, + delta_x: int | Omit = omit, + delta_y: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.scroll( + self._session_id, + x=x, + y=y, + delta_x=delta_x, + delta_y=delta_y, + hold_keys=hold_keys, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def set_cursor_visibility( + self, + *, + hidden: bool, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ComputerSetCursorVisibilityResponse: + return self._inner.set_cursor_visibility( + self._session_id, + hidden=hidden, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def type_text( + self, + *, + text: str, + delay: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.type_text( + self._session_id, + text=text, + delay=delay, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def write_clipboard( + self, + *, + text: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.write_clipboard( + self._session_id, + text=text, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundAsyncComputerResource(ScopedResourceProxy): + """Async variant: session id is injected for browser API methods.""" + + async def batch( + self, + *, + actions: Iterable[computer_batch_params.Action], + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.batch( + self._session_id, + actions=actions, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def capture_screenshot( + self, + *, + region: computer_capture_screenshot_params.Region | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncBinaryAPIResponse: + return await self._inner.capture_screenshot( + self._session_id, + region=region, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def click_mouse( + self, + *, + x: int, + y: int, + button: Literal["left", "right", "middle", "back", "forward"] | Omit = omit, + click_type: Literal["down", "up", "click"] | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + num_clicks: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.click_mouse( + self._session_id, + x=x, + y=y, + button=button, + click_type=click_type, + hold_keys=hold_keys, + num_clicks=num_clicks, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def drag_mouse( + self, + *, + path: Iterable[Iterable[int]], + button: Literal["left", "middle", "right"] | Omit = omit, + delay: int | Omit = omit, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: bool | Omit = omit, + step_delay_ms: int | Omit = omit, + steps_per_segment: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.drag_mouse( + self._session_id, + path=path, + button=button, + delay=delay, + duration_ms=duration_ms, + hold_keys=hold_keys, + smooth=smooth, + step_delay_ms=step_delay_ms, + steps_per_segment=steps_per_segment, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def get_mouse_position( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ComputerGetMousePositionResponse: + return await self._inner.get_mouse_position( + self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def move_mouse( + self, + *, + x: int, + y: int, + duration_ms: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + smooth: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.move_mouse( + self._session_id, + x=x, + y=y, + duration_ms=duration_ms, + hold_keys=hold_keys, + smooth=smooth, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def press_key( + self, + *, + keys: SequenceNotStr[str], + duration: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.press_key( + self._session_id, + keys=keys, + duration=duration, + hold_keys=hold_keys, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def read_clipboard( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ComputerReadClipboardResponse: + return await self._inner.read_clipboard( + self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def scroll( + self, + *, + x: int, + y: int, + delta_x: int | Omit = omit, + delta_y: int | Omit = omit, + hold_keys: SequenceNotStr[str] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.scroll( + self._session_id, + x=x, + y=y, + delta_x=delta_x, + delta_y=delta_y, + hold_keys=hold_keys, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def set_cursor_visibility( + self, + *, + hidden: bool, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ComputerSetCursorVisibilityResponse: + return await self._inner.set_cursor_visibility( + self._session_id, + hidden=hidden, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def type_text( + self, + *, + text: str, + delay: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.type_text( + self._session_id, + text=text, + delay=delay, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def write_clipboard( + self, + *, + text: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.write_clipboard( + self._session_id, + text=text, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundLogsResource(ScopedResourceProxy): + """Session id is injected for browser API methods.""" + + def stream( + self, + *, + source: Literal["path", "supervisor"], + follow: bool | Omit = omit, + path: str | Omit = omit, + supervisor_process: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Stream[LogEvent]: + return self._inner.stream( + self._session_id, + source=source, + follow=follow, + path=path, + supervisor_process=supervisor_process, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundAsyncLogsResource(ScopedResourceProxy): + """Async variant: session id is injected for browser API methods.""" + + async def stream( + self, + *, + source: Literal["path", "supervisor"], + follow: bool | Omit = omit, + path: str | Omit = omit, + supervisor_process: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncStream[LogEvent]: + return await self._inner.stream( + self._session_id, + source=source, + follow=follow, + path=path, + supervisor_process=supervisor_process, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundPlaywrightResource(ScopedResourceProxy): + """Session id is injected for browser API methods.""" + + def execute( + self, + *, + code: str, + timeout_sec: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> PlaywrightExecuteResponse: + return self._inner.execute( + self._session_id, + code=code, + timeout_sec=timeout_sec, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundAsyncPlaywrightResource(ScopedResourceProxy): + """Async variant: session id is injected for browser API methods.""" + + async def execute( + self, + *, + code: str, + timeout_sec: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> PlaywrightExecuteResponse: + return await self._inner.execute( + self._session_id, + code=code, + timeout_sec=timeout_sec, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundProcessResource(ScopedResourceProxy): + """Session id is injected for browser API methods.""" + + def exec( + self, + *, + command: str, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + timeout_sec: Optional[int] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessExecResponse: + return self._inner.exec( + self._session_id, + command=command, + args=args, + as_root=as_root, + as_user=as_user, + cwd=cwd, + env=env, + timeout_sec=timeout_sec, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def kill( + self, + process_id: str, + *, + signal: Literal["TERM", "KILL", "INT", "HUP"], + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessKillResponse: + return self._inner.kill( + process_id, + id=self._session_id, + signal=signal, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def resize( + self, + process_id: str, + *, + cols: int, + rows: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessResizeResponse: + return self._inner.resize( + process_id, + id=self._session_id, + cols=cols, + rows=rows, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def spawn( + self, + *, + command: str, + allocate_tty: bool | Omit = omit, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cols: int | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + rows: int | Omit = omit, + timeout_sec: Optional[int] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessSpawnResponse: + return self._inner.spawn( + self._session_id, + command=command, + allocate_tty=allocate_tty, + args=args, + as_root=as_root, + as_user=as_user, + cols=cols, + cwd=cwd, + env=env, + rows=rows, + timeout_sec=timeout_sec, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def status( + self, + process_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessStatusResponse: + return self._inner.status( + process_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def stdin( + self, + process_id: str, + *, + data_b64: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessStdinResponse: + return self._inner.stdin( + process_id, + id=self._session_id, + data_b64=data_b64, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def stdout_stream( + self, + process_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Stream[ProcessStdoutStreamResponse]: + return self._inner.stdout_stream( + process_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundAsyncProcessResource(ScopedResourceProxy): + """Async variant: session id is injected for browser API methods.""" + + async def exec( + self, + *, + command: str, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + timeout_sec: Optional[int] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessExecResponse: + return await self._inner.exec( + self._session_id, + command=command, + args=args, + as_root=as_root, + as_user=as_user, + cwd=cwd, + env=env, + timeout_sec=timeout_sec, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def kill( + self, + process_id: str, + *, + signal: Literal["TERM", "KILL", "INT", "HUP"], + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessKillResponse: + return await self._inner.kill( + process_id, + id=self._session_id, + signal=signal, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def resize( + self, + process_id: str, + *, + cols: int, + rows: int, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessResizeResponse: + return await self._inner.resize( + process_id, + id=self._session_id, + cols=cols, + rows=rows, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def spawn( + self, + *, + command: str, + allocate_tty: bool | Omit = omit, + args: SequenceNotStr[str] | Omit = omit, + as_root: bool | Omit = omit, + as_user: Optional[str] | Omit = omit, + cols: int | Omit = omit, + cwd: Optional[str] | Omit = omit, + env: Dict[str, str] | Omit = omit, + rows: int | Omit = omit, + timeout_sec: Optional[int] | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessSpawnResponse: + return await self._inner.spawn( + self._session_id, + command=command, + allocate_tty=allocate_tty, + args=args, + as_root=as_root, + as_user=as_user, + cols=cols, + cwd=cwd, + env=env, + rows=rows, + timeout_sec=timeout_sec, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def status( + self, + process_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessStatusResponse: + return await self._inner.status( + process_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def stdin( + self, + process_id: str, + *, + data_b64: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ProcessStdinResponse: + return await self._inner.stdin( + process_id, + id=self._session_id, + data_b64=data_b64, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def stdout_stream( + self, + process_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncStream[ProcessStdoutStreamResponse]: + return await self._inner.stdout_stream( + process_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundReplaysResource(ScopedResourceProxy): + """Session id is injected for browser API methods.""" + + def list( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReplayListResponse: + return self._inner.list( + self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def download( + self, + replay_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BinaryAPIResponse: + return self._inner.download( + replay_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def start( + self, + *, + framerate: int | Omit = omit, + max_duration_in_seconds: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReplayStartResponse: + return self._inner.start( + self._session_id, + framerate=framerate, + max_duration_in_seconds=max_duration_in_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def stop( + self, + replay_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.stop( + replay_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundAsyncReplaysResource(ScopedResourceProxy): + """Async variant: session id is injected for browser API methods.""" + + async def list( + self, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReplayListResponse: + return await self._inner.list( + self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def download( + self, + replay_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncBinaryAPIResponse: + return await self._inner.download( + replay_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def start( + self, + *, + framerate: int | Omit = omit, + max_duration_in_seconds: int | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> ReplayStartResponse: + return await self._inner.start( + self._session_id, + framerate=framerate, + max_duration_in_seconds=max_duration_in_seconds, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def stop( + self, + replay_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.stop( + replay_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundWatchResource(ScopedResourceProxy): + """Session id is injected for browser API methods.""" + + def events( + self, + watch_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Stream[WatchEventsResponse]: + return self._inner.events( + watch_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def start( + self, + *, + path: str, + recursive: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> WatchStartResponse: + return self._inner.start( + self._session_id, + path=path, + recursive=recursive, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def stop( + self, + watch_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.stop( + watch_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundAsyncWatchResource(ScopedResourceProxy): + """Async variant: session id is injected for browser API methods.""" + + async def events( + self, + watch_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncStream[WatchEventsResponse]: + return await self._inner.events( + watch_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def start( + self, + *, + path: str, + recursive: bool | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> WatchStartResponse: + return await self._inner.start( + self._session_id, + path=path, + recursive=recursive, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def stop( + self, + watch_id: str, + *, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.stop( + watch_id, + id=self._session_id, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundFsResource(ScopedResourceProxy): + """Session id is injected for browser API methods.""" + + @cached_property + def watch(self) -> BoundWatchResource: + from ...resources.browsers.fs.watch import WatchResource + + return BoundWatchResource(WatchResource(self._inner._client), self._session_id) + + def create_directory( + self, + *, + path: str, + mode: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.create_directory( + self._session_id, + path=path, + mode=mode, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def delete_directory( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.delete_directory( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def delete_file( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.delete_file( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def download_dir_zip( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BinaryAPIResponse: + return self._inner.download_dir_zip( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def file_info( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FFileInfoResponse: + return self._inner.file_info( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def list_files( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FListFilesResponse: + return self._inner.list_files( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def move( + self, + *, + dest_path: str, + src_path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.move( + self._session_id, + dest_path=dest_path, + src_path=src_path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def read_file( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> BinaryAPIResponse: + return self._inner.read_file( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def set_file_permissions( + self, + *, + mode: str, + path: str, + group: str | Omit = omit, + owner: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.set_file_permissions( + self._session_id, + mode=mode, + path=path, + group=group, + owner=owner, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def upload( + self, + *, + files: Iterable[f_upload_params.File], + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.upload( + self._session_id, + files=files, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def upload_zip( + self, + *, + dest_path: str, + zip_file: FileTypes, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.upload_zip( + self._session_id, + dest_path=dest_path, + zip_file=zip_file, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + def write_file( + self, + contents: FileContent | BinaryTypes, + *, + path: str, + mode: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return self._inner.write_file( + self._session_id, + contents, + path=path, + mode=mode, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BoundAsyncFsResource(ScopedResourceProxy): + """Async variant: session id is injected for browser API methods.""" + + @cached_property + def watch(self) -> BoundAsyncWatchResource: + from ...resources.browsers.fs.watch import AsyncWatchResource + + return BoundAsyncWatchResource(AsyncWatchResource(self._inner._client), self._session_id) + + async def create_directory( + self, + *, + path: str, + mode: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.create_directory( + self._session_id, + path=path, + mode=mode, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def delete_directory( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.delete_directory( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def delete_file( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.delete_file( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def download_dir_zip( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncBinaryAPIResponse: + return await self._inner.download_dir_zip( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def file_info( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FFileInfoResponse: + return await self._inner.file_info( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def list_files( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> FListFilesResponse: + return await self._inner.list_files( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def move( + self, + *, + dest_path: str, + src_path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.move( + self._session_id, + dest_path=dest_path, + src_path=src_path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def read_file( + self, + *, + path: str, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncBinaryAPIResponse: + return await self._inner.read_file( + self._session_id, + path=path, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def set_file_permissions( + self, + *, + mode: str, + path: str, + group: str | Omit = omit, + owner: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.set_file_permissions( + self._session_id, + mode=mode, + path=path, + group=group, + owner=owner, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def upload( + self, + *, + files: Iterable[f_upload_params.File], + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.upload( + self._session_id, + files=files, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def upload_zip( + self, + *, + dest_path: str, + zip_file: FileTypes, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.upload_zip( + self._session_id, + dest_path=dest_path, + zip_file=zip_file, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + async def write_file( + self, + contents: FileContent | AsyncBinaryTypes, + *, + path: str, + mode: str | Omit = omit, + extra_headers: Headers | None = None, + extra_query: Query | None = None, + extra_body: Body | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> None: + return await self._inner.write_file( + self._session_id, + contents, + path=path, + mode=mode, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + ) + + +class BrowserScopedFacadeMixin: + """Top-level browser session subresources (sync); uses `_http` and `session_id`.""" + + _http: Any + session_id: str + + @cached_property + def computer(self) -> BoundComputerResource: + from ...resources.browsers.computer import ComputerResource + + return BoundComputerResource(ComputerResource(self._http), self.session_id) + + @cached_property + def fs(self) -> BoundFsResource: + from ...resources.browsers.fs.fs import FsResource + + return BoundFsResource(FsResource(self._http), self.session_id) + + @cached_property + def logs(self) -> BoundLogsResource: + from ...resources.browsers.logs import LogsResource + + return BoundLogsResource(LogsResource(self._http), self.session_id) + + @cached_property + def playwright(self) -> BoundPlaywrightResource: + from ...resources.browsers.playwright import PlaywrightResource + + return BoundPlaywrightResource(PlaywrightResource(self._http), self.session_id) + + @cached_property + def process(self) -> BoundProcessResource: + from ...resources.browsers.process import ProcessResource + + return BoundProcessResource(ProcessResource(self._http), self.session_id) + + @cached_property + def replays(self) -> BoundReplaysResource: + from ...resources.browsers.replays import ReplaysResource + + return BoundReplaysResource(ReplaysResource(self._http), self.session_id) + + +class AsyncBrowserScopedFacadeMixin: + """Top-level browser session subresources (async); uses `_http` and `session_id`.""" + + _http: Any + session_id: str + + @cached_property + def computer(self) -> BoundAsyncComputerResource: + from ...resources.browsers.computer import AsyncComputerResource + + return BoundAsyncComputerResource(AsyncComputerResource(self._http), self.session_id) + + @cached_property + def fs(self) -> BoundAsyncFsResource: + from ...resources.browsers.fs.fs import AsyncFsResource + + return BoundAsyncFsResource(AsyncFsResource(self._http), self.session_id) + + @cached_property + def logs(self) -> BoundAsyncLogsResource: + from ...resources.browsers.logs import AsyncLogsResource + + return BoundAsyncLogsResource(AsyncLogsResource(self._http), self.session_id) + + @cached_property + def playwright(self) -> BoundAsyncPlaywrightResource: + from ...resources.browsers.playwright import AsyncPlaywrightResource + + return BoundAsyncPlaywrightResource(AsyncPlaywrightResource(self._http), self.session_id) + + @cached_property + def process(self) -> BoundAsyncProcessResource: + from ...resources.browsers.process import AsyncProcessResource + + return BoundAsyncProcessResource(AsyncProcessResource(self._http), self.session_id) + + @cached_property + def replays(self) -> BoundAsyncReplaysResource: + from ...resources.browsers.replays import AsyncReplaysResource + + return BoundAsyncReplaysResource(AsyncReplaysResource(self._http), self.session_id) + + +__all__ = [ + "BoundAsyncComputerResource", + "BoundAsyncFsResource", + "BoundAsyncLogsResource", + "BoundAsyncPlaywrightResource", + "BoundAsyncProcessResource", + "BoundAsyncReplaysResource", + "BoundAsyncWatchResource", + "BoundComputerResource", + "BoundFsResource", + "BoundLogsResource", + "BoundPlaywrightResource", + "BoundProcessResource", + "BoundReplaysResource", + "BoundWatchResource", +] diff --git a/src/kernel/lib/browser_scoped/util.py b/src/kernel/lib/browser_scoped/util.py index 9be49245..bddb6dd1 100644 --- a/src/kernel/lib/browser_scoped/util.py +++ b/src/kernel/lib/browser_scoped/util.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect from typing import Any, Mapping, cast from urllib.parse import parse_qs, urlparse @@ -56,3 +57,31 @@ def cdp_ws_url_from_browser_like(browser: Any) -> str: if isinstance(m, str) and m: return m raise TypeError("browser object must have a non-empty cdp_ws_url") + + +class ScopedResourceProxy: + """Delegates to a generated resource; injects `id` for callables that still expose it.""" + + def __init__(self, inner: Any, session_id: str) -> None: + object.__setattr__(self, "_inner", inner) + object.__setattr__(self, "_session_id", session_id) + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError(name) + attr = getattr(self._inner, name) + if name.startswith("with_") or not callable(attr): + return attr + try: + sig = inspect.signature(attr) + except (TypeError, ValueError): + return attr + if "id" not in sig.parameters: + return attr + + def bound(*args: Any, **kwargs: Any) -> Any: + kw = dict(kwargs) + kw["id"] = self._session_id + return attr(*args, **kw) + + return bound From 0bdf85e0c38d4813056b61599273e88c7a64713a Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:32:00 -0400 Subject: [PATCH 07/17] fix: quiet generator-script pyright noise Keep the browser-scoped Python generator compatible with the repo lint pipeline by suppressing strict pyright diagnostics that are not meaningful for the AST-walking build script. Made-with: Cursor --- scripts/generate_browser_scoped.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/generate_browser_scoped.py b/scripts/generate_browser_scoped.py index b682dbc6..c47b88a6 100644 --- a/scripts/generate_browser_scoped.py +++ b/scripts/generate_browser_scoped.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Generate browser-scoped binding classes from AST of src/kernel/resources/browsers/**.""" +# pyright: reportUnknownParameterType=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUndefinedVariable=false, reportUnusedVariable=false from __future__ import annotations From b410245e1ad4bf8e29c17c59a5931654567b141f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:34:08 -0400 Subject: [PATCH 08/17] fix: satisfy generated browser-scoped type checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the Python generator and generated browser-scoped façade aligned with pyright and mypy so the deterministic regeneration path passes the repo lint pipeline. Made-with: Cursor --- scripts/generate_browser_scoped.py | 1 + src/kernel/lib/browser_scoped/client.py | 6 +++--- src/kernel/lib/browser_scoped/generated_bindings.py | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/generate_browser_scoped.py b/scripts/generate_browser_scoped.py index c47b88a6..2f8cc811 100644 --- a/scripts/generate_browser_scoped.py +++ b/scripts/generate_browser_scoped.py @@ -593,6 +593,7 @@ def _emit_module() -> str: "# Code generated by scripts/generate_browser_scoped.py. DO NOT EDIT.", "# ruff: noqa: I001, F401", "# pyright: reportUnusedImport=false", + "# mypy: ignore-errors", '"""Browser-scoped wrappers over generated `resources.browsers` classes (AST-driven)."""', "", "from __future__ import annotations", diff --git a/src/kernel/lib/browser_scoped/client.py b/src/kernel/lib/browser_scoped/client.py index 8c6b31e2..9575bbcb 100644 --- a/src/kernel/lib/browser_scoped/client.py +++ b/src/kernel/lib/browser_scoped/client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import IO, TYPE_CHECKING, Any, Mapping +from typing import IO, TYPE_CHECKING, Any, Mapping, cast from contextlib import contextmanager, asynccontextmanager from collections.abc import Iterable, Iterator, AsyncIterator @@ -68,7 +68,7 @@ def request( json_data=json, timeout=_normalize_timeout(timeout), ) - return self._http.request(httpx.Response, opts) + return cast(httpx.Response, self._http.request(httpx.Response, opts)) @contextmanager def stream( @@ -143,7 +143,7 @@ async def request( json_data=json, timeout=_normalize_timeout(timeout), ) - return await self._http.request(httpx.Response, opts) + return cast(httpx.Response, await self._http.request(httpx.Response, opts)) @asynccontextmanager async def stream( diff --git a/src/kernel/lib/browser_scoped/generated_bindings.py b/src/kernel/lib/browser_scoped/generated_bindings.py index b6ac723e..1a9524c2 100644 --- a/src/kernel/lib/browser_scoped/generated_bindings.py +++ b/src/kernel/lib/browser_scoped/generated_bindings.py @@ -1,6 +1,7 @@ # Code generated by scripts/generate_browser_scoped.py. DO NOT EDIT. # ruff: noqa: I001, F401 # pyright: reportUnusedImport=false +# mypy: ignore-errors """Browser-scoped wrappers over generated `resources.browsers` classes (AST-driven).""" from __future__ import annotations From a80716b791bf1f707aa7869290c47caefb0d9e27 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:36:26 -0400 Subject: [PATCH 09/17] chore: keep browser-scoped generator lint clean Sort the generator script imports and keep the deterministic browser-scoped generation path aligned with the repo lint pipeline. Made-with: Cursor --- scripts/generate_browser_scoped.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/generate_browser_scoped.py b/scripts/generate_browser_scoped.py index 2f8cc811..2b8a03b2 100644 --- a/scripts/generate_browser_scoped.py +++ b/scripts/generate_browser_scoped.py @@ -5,6 +5,7 @@ from __future__ import annotations import ast +from typing import Iterable from pathlib import Path from dataclasses import dataclass @@ -618,9 +619,9 @@ def _emit_module() -> str: parts.append("") facade_entries = _facade_entries_from_browsers_py() - for _prop, sync_cls in facade_entries: - if sync_cls not in _class_file_cache: - raise RuntimeError(f"facade references unknown resource class {sync_cls!r}") + for _prop, facade_sync_name in facade_entries: + if facade_sync_name not in _class_file_cache: + raise RuntimeError(f"facade references unknown resource class {facade_sync_name!r}") parts.append(_emit_facade_mixins(facade_entries)) parts.append("") From ca5d1884b590634df5623945e9585e0a66228ec3 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 21 Apr 2026 16:24:57 -0400 Subject: [PATCH 10/17] docs: flesh out browser-scoped example Turn the browser-scoped Python example into a runnable demonstration of both process execution and /curl/raw-backed request and stream usage. Made-with: Cursor --- examples/browser_scoped.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/examples/browser_scoped.py b/examples/browser_scoped.py index e409517a..06273319 100644 --- a/examples/browser_scoped.py +++ b/examples/browser_scoped.py @@ -2,17 +2,22 @@ from kernel import Kernel -# After creating or loading a browser session (with base_url + cdp_ws_url from the API): -# browser = client.browsers.create(...) -# scoped = client.for_browser(browser) -# scoped.process.exec(command="uname", args=["-a"]) -# r = scoped.request("GET", "https://example.com") -# with scoped.stream("GET", "https://example.com") as resp: -# print(resp.read()) - def main() -> None: - _ = Kernel + with Kernel() as client: + browser = client.browsers.create(headless=True) + try: + scoped = client.for_browser(browser) + + scoped.process.exec(command="uname", args=["-a"]) + + response = scoped.request("GET", "https://example.com") + print("status", response.status_code) + + with scoped.stream("GET", "https://example.com") as streamed: + print("streamed-bytes", len(streamed.read())) + finally: + client.browsers.delete_by_id(browser.session_id) if __name__ == "__main__": From dba503e832d54aa8d462d3d74b3027f8a9e865b6 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 10:58:03 -0400 Subject: [PATCH 11/17] refactor: drop browser-scoped wrapper clients Move browser raw HTTP and direct-to-VM routing onto the main browsers resource so the SDK uses the shared browser route cache instead of a generated wrapper layer. Made-with: Cursor --- examples/browser_scoped.py | 14 +- scripts/generate_browser_scoped.py | 652 ------ scripts/lint | 7 - src/kernel/__init__.py | 2 + src/kernel/_client.py | 55 +- src/kernel/lib/browser_scoped/__init__.py | 4 +- src/kernel/lib/browser_scoped/client.py | 216 -- .../lib/browser_scoped/generated_bindings.py | 1946 ----------------- src/kernel/lib/browser_scoped/raw_http.py | 152 ++ src/kernel/lib/browser_scoped/routing.py | 127 ++ src/kernel/resources/browsers/browsers.py | 154 +- tests/test_browser_scoped.py | 95 +- 12 files changed, 561 insertions(+), 2863 deletions(-) delete mode 100644 scripts/generate_browser_scoped.py delete mode 100644 src/kernel/lib/browser_scoped/client.py delete mode 100644 src/kernel/lib/browser_scoped/generated_bindings.py create mode 100644 src/kernel/lib/browser_scoped/raw_http.py create mode 100644 src/kernel/lib/browser_scoped/routing.py diff --git a/examples/browser_scoped.py b/examples/browser_scoped.py index 06273319..81227dbd 100644 --- a/examples/browser_scoped.py +++ b/examples/browser_scoped.py @@ -1,20 +1,20 @@ -"""Example: browser-scoped client for browser VM process exec and raw HTTP.""" +"""Example: direct-to-VM browser routing for process exec and raw HTTP.""" -from kernel import Kernel +from kernel import BrowserRoutingConfig, Kernel def main() -> None: - with Kernel() as client: + with Kernel(browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",))) as client: browser = client.browsers.create(headless=True) try: - scoped = client.for_browser(browser) + client.prime_browser_route_cache(browser) - scoped.process.exec(command="uname", args=["-a"]) + client.browsers.process.exec(browser.session_id, command="uname", args=["-a"]) - response = scoped.request("GET", "https://example.com") + response = client.browsers.request(browser.session_id, "GET", "https://example.com") print("status", response.status_code) - with scoped.stream("GET", "https://example.com") as streamed: + with client.browsers.stream(browser.session_id, "GET", "https://example.com") as streamed: print("streamed-bytes", len(streamed.read())) finally: client.browsers.delete_by_id(browser.session_id) diff --git a/scripts/generate_browser_scoped.py b/scripts/generate_browser_scoped.py deleted file mode 100644 index 2b8a03b2..00000000 --- a/scripts/generate_browser_scoped.py +++ /dev/null @@ -1,652 +0,0 @@ -#!/usr/bin/env python3 -"""Generate browser-scoped binding classes from AST of src/kernel/resources/browsers/**.""" -# pyright: reportUnknownParameterType=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUndefinedVariable=false, reportUnusedVariable=false - -from __future__ import annotations - -import ast -from typing import Iterable -from pathlib import Path -from dataclasses import dataclass - - -@dataclass(frozen=True) -class IdBinding: - kind: str # "positional" | "kwonly" - - -def _repo_root() -> Path: - return Path(__file__).resolve().parent.parent - - -def _browsers_root() -> Path: - return _repo_root() / "src/kernel/resources/browsers" - - -def _iter_browser_py_files() -> list[Path]: - root = _browsers_root() - out: list[Path] = [] - for p in sorted(root.rglob("*.py")): - if p.name in ("__init__.py", "browsers.py"): - continue - out.append(p) - return out - - -def _is_resource_class(node: ast.ClassDef) -> bool: - if not node.name.endswith("Resource"): - return False - if node.name.startswith("Async"): - return False - if "With" in node.name: - return False - for b in node.bases: - if isinstance(b, ast.Name) and b.id == "SyncAPIResource": - return True - if isinstance(b, ast.Attribute) and b.attr == "SyncAPIResource": - return True - return False - - -def _is_async_resource_class(node: ast.ClassDef) -> bool: - if not node.name.startswith("Async") or not node.name.endswith("Resource"): - return False - if "With" in node.name: - return False - for b in node.bases: - if isinstance(b, ast.Name) and b.id == "AsyncAPIResource": - return True - if isinstance(b, ast.Attribute) and b.attr == "AsyncAPIResource": - return True - return False - - -def _async_resource_name(sync_name: str) -> str: - if sync_name.startswith("Async"): - return sync_name - return f"Async{sync_name}" - - -def _has_cached_property_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: - for d in node.decorator_list: - if isinstance(d, ast.Name) and d.id == "cached_property": - return True - if isinstance(d, ast.Attribute) and d.attr == "cached_property": - return True - return False - - -def _annotation_root_name(node: ast.AST | None) -> str | None: - if node is None: - return None - if isinstance(node, ast.Name): - return node.id - if isinstance(node, ast.Subscript): - return _annotation_root_name(node.value) - if isinstance(node, ast.Attribute): - return node.attr - if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): - return _annotation_root_name(node.left) or _annotation_root_name(node.right) - return None - - -def _find_id_binding(arguments: ast.arguments) -> IdBinding | None: - pos = list(arguments.posonlyargs) + list(arguments.args) - if len(pos) > 0 and pos[0].arg == "self": - rest = pos[1:] - else: - rest = pos - for a in rest: - if a.arg == "id": - return IdBinding("positional") - for a in arguments.kwonlyargs: - if a.arg == "id": - return IdBinding("kwonly") - return None - - -def _strip_id_from_arguments(arguments: ast.arguments) -> ast.arguments: - """Remove `id` from positional and keyword-only parameters; fix defaults tail.""" - - posonly = list(arguments.posonlyargs) - pos = list(arguments.args) - combined = posonly + pos - defaults = list(arguments.defaults or []) - nd = len(defaults) - - kept: list[tuple[ast.arg, ast.expr | None, str]] = [] - for i, a in enumerate(combined): - if a.arg == "id": - continue - d: ast.expr | None = None - if nd and i >= len(combined) - nd: - d = defaults[i - (len(combined) - nd)] - kind = "posonly" if i < len(posonly) else "pos" - kept.append((a, d, kind)) - - new_posonly = [a for a, _d, k in kept if k == "posonly"] - new_pos = [a for a, _d, k in kept if k == "pos"] - new_combined = new_posonly + new_pos - new_defaults_list = [d for a, d, k in kept if d is not None] - new_nd = len(new_defaults_list) - if new_nd > len(new_combined): - raise RuntimeError("invalid defaults after strip") - new_defaults = new_defaults_list[-new_nd:] if new_nd else [] - - kwonly = [a for a in arguments.kwonlyargs if a.arg != "id"] - kw_defaults_old = list(arguments.kw_defaults or []) - new_kw_defaults: list[ast.expr | None] = [] - for i, a in enumerate(arguments.kwonlyargs): - d = kw_defaults_old[i] if i < len(kw_defaults_old) else None - if a.arg != "id": - new_kw_defaults.append(d) - - return ast.arguments( - posonlyargs=new_posonly, - args=new_pos, - kwonlyargs=kwonly, - kw_defaults=new_kw_defaults, - defaults=new_defaults, - vararg=arguments.vararg, - kwarg=arguments.kwarg, - ) - - -def _public_signature(inner: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.arguments: - if _find_id_binding(inner.args) is None: - return inner.args - return _strip_id_from_arguments(inner.args) - - -def _without_leading_self(arguments: ast.arguments) -> ast.arguments: - """Drop `self` from positional args for use in subclass method signatures (posonly unused here).""" - - if arguments.posonlyargs: - raise RuntimeError("positional-only parameters are not supported for browser binding generation") - args = list(arguments.args) - defaults = list(arguments.defaults or []) - if not args or args[0].arg != "self": - raise RuntimeError("expected leading self parameter") - new_args = args[1:] - n_old = len(args) - n_new = len(new_args) - nd = len(defaults) - if nd: - if nd > n_old: - raise RuntimeError("too many defaults") - new_defaults = defaults[-min(nd, n_new) :] if n_new else [] - else: - new_defaults = [] - return ast.arguments( - posonlyargs=[], - args=new_args, - kwonlyargs=list(arguments.kwonlyargs), - kw_defaults=list(arguments.kw_defaults or []), - defaults=new_defaults, - vararg=arguments.vararg, - kwarg=arguments.kwarg, - ) - - -def _emit_call_forward(inner_name: str, inner: ast.FunctionDef | ast.AsyncFunctionDef) -> str: - binding = _find_id_binding(inner.args) - pos_all = list(inner.args.posonlyargs) + list(inner.args.args) - if not pos_all or pos_all[0].arg != "self": - raise RuntimeError(f"expected self first on {inner_name}") - rest_pos = pos_all[1:] - - pos_call: list[str] = [] - for a in rest_pos: - if a.arg == "id": - pos_call.append("self._session_id") - else: - pos_call.append(a.arg) - - kw_parts: list[str] = [] - for a in inner.args.kwonlyargs: - if a.arg == "id": - kw_parts.append("id=self._session_id") - else: - kw_parts.append(f"{a.arg}={a.arg}") - - if inner.args.vararg is not None or inner.args.kwarg is not None: - raise RuntimeError(f"unsupported vararg/kwarg on {inner_name}") - - if binding is None: - inner_pos = ", ".join(a.arg for a in rest_pos) - inner_kw = ", ".join(f"{a.arg}={a.arg}" for a in inner.args.kwonlyargs) - bits = [inner_pos] if inner_pos else [] - if inner_kw: - bits.append(inner_kw) - return f"self._inner.{inner_name}({', '.join(bits)})" - - return f"self._inner.{inner_name}({', '.join([*pos_call, *kw_parts])})" - - -def _emit_method( - inner: ast.FunctionDef | ast.AsyncFunctionDef, - *, - is_async: bool, -) -> str | None: - if inner.name.startswith("_"): - return None - if _has_cached_property_decorator(inner): - return None - - binding = _find_id_binding(inner.args) - if binding is None: - return None - - pub_args = _without_leading_self(_public_signature(inner)) - ret = inner.returns - ret_s = "" if ret is None else f" -> {ast.unparse(ret)}" - prefix = "async def" if is_async else "def" - await_kw = "await " if is_async else "" - body = f"return {await_kw}{_emit_call_forward(inner.name, inner)}" - - args_s = ast.unparse(pub_args) - if args_s.startswith("(") and args_s.endswith(")"): - inner_args = args_s[1:-1].strip() - else: - inner_args = args_s.strip() - if inner_args: - sig_inner = f"self, {inner_args}" - else: - sig_inner = "self" - - lines = [f" {prefix} {inner.name}({sig_inner}){ret_s}:", f" {body}"] - return "\n".join(lines) - - -def _bound_class_name(sync_cls: str) -> str: - return f"Bound{sync_cls}" - - -def _import_line_for_class(file_path: Path, class_name: str) -> str: - rel = file_path.relative_to(_repo_root() / "src/kernel") - mod = ".".join(rel.with_suffix("").parts) - return f"from ...{mod} import {class_name}" - - -def _discover_nested_subresources(sync_class: ast.ClassDef) -> list[tuple[str, str]]: - out: list[tuple[str, str]] = [] - for node in sync_class.body: - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - if not _has_cached_property_decorator(node): - continue - if node.name.startswith("with_"): - continue - root = _annotation_root_name(node.returns) - if root is None: - continue - if not root.endswith("Resource") or root.startswith("Async"): - continue - if "With" in root: - continue - out.append((node.name, root)) - return out - - -def _collect_sync_resource_classes(tree: ast.Module) -> dict[str, ast.ClassDef]: - out: dict[str, ast.ClassDef] = {} - for node in tree.body: - if isinstance(node, ast.ClassDef) and _is_resource_class(node): - out[node.name] = node - return out - - -def _collect_async_resource_classes(tree: ast.Module) -> dict[str, ast.ClassDef]: - out: dict[str, ast.ClassDef] = {} - for node in tree.body: - if isinstance(node, ast.ClassDef) and _is_async_resource_class(node): - out[node.name] = node - return out - - -def _emit_bound_class_pair( - sync_name: str, - sync_cls: ast.ClassDef, - async_cls: ast.ClassDef | None, - nested: dict[str, list[tuple[str, str]]], -) -> str: - bound = _bound_class_name(sync_name) - lines: list[str] = [ - f"class {bound}(ScopedResourceProxy):", - ' """Session id is injected for browser API methods."""', - ] - - for prop_name, inner_cls in nested.get(sync_name, []): - ib = _bound_class_name(inner_cls) - imp = _import_line_for_class(_class_file(inner_cls), inner_cls) - lines.append(" @cached_property") - lines.append(f" def {prop_name}(self) -> {ib}:") - lines.append(f" {imp}") - lines.append(f" return {ib}({inner_cls}(self._inner._client), self._session_id)") - lines.append("") - - for node in sync_cls.body: - if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"): - chunk = _emit_method(node, is_async=False) - if chunk: - lines.append(chunk) - lines.append("") - - if async_cls is not None: - an = _async_resource_name(sync_name) - bound_a = _bound_class_name(an) - lines.append("") - lines.append(f"class {bound_a}(ScopedResourceProxy):") - lines.append(' """Async variant: session id is injected for browser API methods."""') - - for prop_name, inner_cls in nested.get(sync_name, []): - ainner = _async_resource_name(inner_cls) - ib = _bound_class_name(ainner) - imp = _import_line_for_class(_class_file(inner_cls), ainner) - lines.append(" @cached_property") - lines.append(f" def {prop_name}(self) -> {ib}:") - lines.append(f" {imp}") - lines.append(f" return {ib}({ainner}(self._inner._client), self._session_id)") - lines.append("") - - for node in async_cls.body: - if isinstance(node, ast.AsyncFunctionDef) and not node.name.startswith("_"): - chunk = _emit_method(node, is_async=True) - if chunk: - lines.append(chunk) - lines.append("") - - return "\n".join(lines).rstrip() + "\n" - - -_class_file_cache: dict[str, Path] = {} - - -def _index_classes_by_name() -> None: - global _class_file_cache - _class_file_cache = {} - for path in _iter_browser_py_files(): - tree = ast.parse(path.read_text(encoding="utf-8")) - for name in _collect_sync_resource_classes(tree): - _class_file_cache[name] = path - for name in _collect_async_resource_classes(tree): - _class_file_cache[name] = path - - -def _class_file(class_name: str) -> Path: - return _class_file_cache[class_name] - - -def _nested_map() -> dict[str, list[tuple[str, str]]]: - nested: dict[str, list[tuple[str, str]]] = {} - for path in _iter_browser_py_files(): - tree = ast.parse(path.read_text(encoding="utf-8")) - for name, cls in _collect_sync_resource_classes(tree).items(): - pairs = _discover_nested_subresources(cls) - if pairs: - nested[name] = pairs - return nested - - -def _browsers_py_path() -> Path: - return _browsers_root() / "browsers.py" - - -def _cached_property_resource_subresources(cls: ast.ClassDef) -> dict[str, str]: - """prop_name -> sync Resource class name for @cached_property -> XResource style members.""" - - out: dict[str, str] = {} - for node in cls.body: - if not isinstance(node, ast.FunctionDef): - continue - if not _has_cached_property_decorator(node): - continue - if node.name.startswith("with_"): - continue - root = _annotation_root_name(node.returns) - if root is None: - continue - if "With" in root or not root.endswith("Resource"): - continue - if root.startswith("Async"): - continue - out[node.name] = root - return out - - -def _facade_entries_from_browsers_py() -> list[tuple[str, str]]: - """Top-level browser subresources from `BrowsersResource` / `AsyncBrowsersResource` (AST).""" - - path = _browsers_py_path() - tree = ast.parse(path.read_text(encoding="utf-8")) - sync_cls: ast.ClassDef | None = None - async_cls: ast.ClassDef | None = None - for node in tree.body: - if isinstance(node, ast.ClassDef) and node.name == "BrowsersResource": - sync_cls = node - elif isinstance(node, ast.ClassDef) and node.name == "AsyncBrowsersResource": - async_cls = node - if sync_cls is None or async_cls is None: - raise RuntimeError(f"expected BrowsersResource and AsyncBrowsersResource in {path}") - - sync_map = _cached_property_resource_subresources(sync_cls) - async_map: dict[str, str] = {} - for node in async_cls.body: - if not isinstance(node, ast.FunctionDef): - continue - if not _has_cached_property_decorator(node): - continue - if node.name.startswith("with_"): - continue - root = _annotation_root_name(node.returns) - if root is None or "With" in root: - continue - if not (root.startswith("Async") and root.endswith("Resource")): - continue - async_map[node.name] = root - - if set(sync_map) != set(async_map): - raise RuntimeError( - "BrowsersResource vs AsyncBrowsersResource cached_property session resources mismatch: " - f"sync={sorted(sync_map)!r} async={sorted(async_map)!r}" - ) - - for prop in sorted(sync_map): - expected = _async_resource_name(sync_map[prop]) - got = async_map[prop] - if got != expected: - raise RuntimeError(f"{path}: property {prop!r}: expected async return {expected!r}, got {got!r}") - - return sorted(sync_map.items(), key=lambda t: t[0]) - - -def _emit_facade_mixins(entries: list[tuple[str, str]]) -> str: - lines: list[str] = [ - "class BrowserScopedFacadeMixin:", - ' """Top-level browser session subresources (sync); uses `_http` and `session_id`."""', - "", - " _http: Any", - " session_id: str", - "", - ] - for prop, sync_cls in entries: - bound = _bound_class_name(sync_cls) - imp = _import_line_for_class(_class_file(sync_cls), sync_cls) - lines.append(" @cached_property") - lines.append(f" def {prop}(self) -> {bound}:") - lines.append(f" {imp}") - lines.append(f" return {bound}({sync_cls}(self._http), self.session_id)") - lines.append("") - - lines.extend( - [ - "", - "class AsyncBrowserScopedFacadeMixin:", - ' """Top-level browser session subresources (async); uses `_http` and `session_id`."""', - "", - " _http: Any", - " session_id: str", - "", - ] - ) - for prop, sync_cls in entries: - async_cls = _async_resource_name(sync_cls) - bound = _bound_class_name(async_cls) - imp = _import_line_for_class(_class_file(sync_cls), async_cls) - lines.append(" @cached_property") - lines.append(f" def {prop}(self) -> {bound}:") - lines.append(f" {imp}") - lines.append(f" return {bound}({async_cls}(self._http), self.session_id)") - lines.append("") - - return "\n".join(lines).rstrip() + "\n" - - -def _generation_order(all_sync: list[str], nested: dict[str, list[tuple[str, str]]]) -> list[str]: - deps: dict[str, set[str]] = {c: set() for c in all_sync} - for parent, pairs in nested.items(): - for _, inner in pairs: - deps.setdefault(parent, set()).add(inner) - - ordered: list[str] = [] - remaining = set(all_sync) - while remaining: - ready = sorted([c for c in remaining if not (deps.get(c, set()) & remaining)]) - if not ready: - raise RuntimeError(f"cycle in nested resources: {remaining}") - for c in ready: - ordered.append(c) - remaining.remove(c) - return ordered - - -def _path_to_module(path: Path) -> str: - src = _repo_root() / "src" - rel = path.resolve().relative_to(src) - return ".".join(rel.with_suffix("").parts) - - -def _import_from_to_absolute(module_file: Path, imp: ast.ImportFrom) -> ast.ImportFrom: - level = imp.level or 0 - if level == 0: - return imp - cur = _path_to_module(module_file) - pkg = ".".join(cur.split(".")[:-1]) - if level > 1: - pkg_parts = pkg.split(".") - up = level - 1 - if len(pkg_parts) < up: - raise ValueError(f"cannot resolve import {ast.dump(imp)} from {module_file}") - pkg = ".".join(pkg_parts[:-up]) - if imp.module: - base = f"{pkg}.{imp.module}" - else: - base = pkg - return ast.ImportFrom(module=base, names=imp.names, level=0) - - -def _imports_from_resource_modules(paths: Iterable[Path]) -> list[str]: - """Collect imports from resource modules, rewritten as absolute `kernel.*` paths.""" - - def skip_line(line: str) -> bool: - if "from __future__ import annotations" in line: - return True - if "kernel._resource import" in line: - return True - if "kernel._utils import" in line: - return True - if "kernel._base_client import" in line: - return True - if "kernel._compat import cached_property" in line: - return True - return False - - seen: set[str] = set() - lines: list[str] = [] - for path in sorted({p.resolve() for p in paths}): - tree = ast.parse(path.read_text(encoding="utf-8")) - for node in tree.body: - if isinstance(node, ast.ImportFrom): - node = _import_from_to_absolute(path, node) - line = ast.unparse(node) - elif isinstance(node, ast.Import): - line = ast.unparse(node) - else: - continue - if skip_line(line): - continue - if line not in seen: - seen.add(line) - lines.append(line) - return lines - - -def _emit_module() -> str: - _index_classes_by_name() - nested = _nested_map() - all_sync = sorted(n for n in _class_file_cache if not n.startswith("Async")) - order = _generation_order(all_sync, nested) - - resource_paths = {_class_file_cache[name] for name in all_sync} - import_lines = _imports_from_resource_modules(resource_paths) - - parts: list[str] = [ - "# Code generated by scripts/generate_browser_scoped.py. DO NOT EDIT.", - "# ruff: noqa: I001, F401", - "# pyright: reportUnusedImport=false", - "# mypy: ignore-errors", - '"""Browser-scoped wrappers over generated `resources.browsers` classes (AST-driven)."""', - "", - "from __future__ import annotations", - "", - "from typing import Any", - "", - "from ..._compat import cached_property", - "from .util import ScopedResourceProxy", - ] - if import_lines: - parts.append("") - parts.extend(import_lines) - parts.append("") - - for sync_name in order: - path = _class_file_cache[sync_name] - tree = ast.parse(path.read_text(encoding="utf-8")) - sync_cls = _collect_sync_resource_classes(tree)[sync_name] - async_name = _async_resource_name(sync_name) - async_cls = _collect_async_resource_classes(tree).get(async_name) - parts.append(_emit_bound_class_pair(sync_name, sync_cls, async_cls, nested)) - parts.append("") - - facade_entries = _facade_entries_from_browsers_py() - for _prop, facade_sync_name in facade_entries: - if facade_sync_name not in _class_file_cache: - raise RuntimeError(f"facade references unknown resource class {facade_sync_name!r}") - parts.append(_emit_facade_mixins(facade_entries)) - parts.append("") - - export_names: list[str] = [] - for sync_name in sorted(all_sync): - export_names.append(_bound_class_name(sync_name)) - an = _async_resource_name(sync_name) - if an in _class_file_cache and an != sync_name: - export_names.append(_bound_class_name(an)) - - parts.append("__all__ = [") - for n in sorted(set(export_names)): - parts.append(f' "{n}",') - parts.append("]") - parts.append("") - return "\n".join(parts) - - -def main() -> int: - out = _repo_root() / "src/kernel/lib/browser_scoped/generated_bindings.py" - text = _emit_module() - out.write_text(text, encoding="utf-8") - print(f"Wrote {out} ({len(text.splitlines())} lines)") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/lint b/scripts/lint index 693344a5..7675e607 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,13 +4,6 @@ set -e cd "$(dirname "$0")/.." -echo "==> Regenerating browser-scoped bindings" -python3 scripts/generate_browser_scoped.py -rye run ruff format src/kernel/lib/browser_scoped/generated_bindings.py - -echo "==> Verifying generated browser-scoped bindings are committed" -git diff --exit-code -- src/kernel/lib/browser_scoped/generated_bindings.py - if [ "$1" = "--fix" ]; then echo "==> Running lints with --fix" rye run fix:ruff diff --git a/src/kernel/__init__.py b/src/kernel/__init__.py index 333d7018..7ceb61cd 100644 --- a/src/kernel/__init__.py +++ b/src/kernel/__init__.py @@ -7,6 +7,7 @@ from ._utils import file_from_path from ._client import ( ENVIRONMENTS, + BrowserRoutingConfig, Client, Kernel, Stream, @@ -79,6 +80,7 @@ "RateLimitError", "InternalServerError", "Timeout", + "BrowserRoutingConfig", "RequestOptions", "Client", "AsyncClient", diff --git a/src/kernel/_client.py b/src/kernel/_client.py index ad0adfad..1356a490 100644 --- a/src/kernel/_client.py +++ b/src/kernel/_client.py @@ -29,6 +29,12 @@ SyncAPIClient, AsyncAPIClient, ) +from .lib.browser_scoped.routing import ( + BrowserRouteCache, + BrowserRoutingConfig, + rewrite_direct_vm_options, + strip_direct_vm_auth, +) if TYPE_CHECKING: from .resources import ( @@ -64,6 +70,7 @@ "Transport", "ProxiesTypes", "RequestOptions", + "BrowserRoutingConfig", "Kernel", "AsyncKernel", "Client", @@ -79,8 +86,10 @@ class Kernel(SyncAPIClient): # client options api_key: str + browser_route_cache: BrowserRouteCache _environment: Literal["production", "development"] | NotGiven + _browser_routing: BrowserRoutingConfig | None def __init__( self, @@ -92,6 +101,7 @@ def __init__( max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, + browser_routing: BrowserRoutingConfig | None = None, # Configure a custom httpx client. # We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. # See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details. @@ -105,6 +115,7 @@ def __init__( # outlining your use-case to help us decide if it should be # part of our public interface in the future. _strict_response_validation: bool = False, + _browser_route_cache: BrowserRouteCache | None = None, ) -> None: """Construct a new synchronous Kernel client instance. @@ -154,6 +165,8 @@ def __init__( custom_query=default_query, _strict_response_validation=_strict_response_validation, ) + self.browser_route_cache = _browser_route_cache or BrowserRouteCache() + self._browser_routing = browser_routing @cached_property def deployments(self) -> DeploymentsResource: @@ -266,6 +279,15 @@ def default_headers(self) -> dict[str, str | Omit]: **self._custom_headers, } + @override + def _prepare_options(self, options: Any) -> Any: + options = cast(Any, super()._prepare_options(options)) + return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing) + + @override + def _prepare_request(self, request: httpx.Request) -> None: + strip_direct_vm_auth(request, cache=self.browser_route_cache) + def copy( self, *, @@ -312,6 +334,8 @@ def copy( max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, + browser_routing=self._browser_routing, + _browser_route_cache=self.browser_route_cache, **_extra_kwargs, ) @@ -319,11 +343,8 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def for_browser(self, browser: Any) -> Any: - """Return a browser-scoped client for session subresources and raw HTTP through the session base_url.""" - from .lib.browser_scoped.client import browser_scoped_from_browser - - return browser_scoped_from_browser(self, browser) + def prime_browser_route_cache(self, browser: Any) -> None: + self.browser_route_cache.prime(browser) @override def _make_status_error( @@ -362,8 +383,10 @@ def _make_status_error( class AsyncKernel(AsyncAPIClient): # client options api_key: str + browser_route_cache: BrowserRouteCache _environment: Literal["production", "development"] | NotGiven + _browser_routing: BrowserRoutingConfig | None def __init__( self, @@ -375,6 +398,7 @@ def __init__( max_retries: int = DEFAULT_MAX_RETRIES, default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, + browser_routing: BrowserRoutingConfig | None = None, # Configure a custom httpx client. # We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`. # See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details. @@ -388,6 +412,7 @@ def __init__( # outlining your use-case to help us decide if it should be # part of our public interface in the future. _strict_response_validation: bool = False, + _browser_route_cache: BrowserRouteCache | None = None, ) -> None: """Construct a new async AsyncKernel client instance. @@ -437,6 +462,8 @@ def __init__( custom_query=default_query, _strict_response_validation=_strict_response_validation, ) + self.browser_route_cache = _browser_route_cache or BrowserRouteCache() + self._browser_routing = browser_routing @cached_property def deployments(self) -> AsyncDeploymentsResource: @@ -549,6 +576,15 @@ def default_headers(self) -> dict[str, str | Omit]: **self._custom_headers, } + @override + async def _prepare_options(self, options: Any) -> Any: + options = cast(Any, await super()._prepare_options(options)) + return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing) + + @override + async def _prepare_request(self, request: httpx.Request) -> None: + strip_direct_vm_auth(request, cache=self.browser_route_cache) + def copy( self, *, @@ -595,6 +631,8 @@ def copy( max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, + browser_routing=self._browser_routing, + _browser_route_cache=self.browser_route_cache, **_extra_kwargs, ) @@ -602,11 +640,8 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def for_browser(self, browser: Any) -> Any: - """Return a browser-scoped client for session subresources and raw HTTP through the session base_url.""" - from .lib.browser_scoped.client import async_browser_scoped_from_browser - - return async_browser_scoped_from_browser(self, browser) + def prime_browser_route_cache(self, browser: Any) -> None: + self.browser_route_cache.prime(browser) @override def _make_status_error( diff --git a/src/kernel/lib/browser_scoped/__init__.py b/src/kernel/lib/browser_scoped/__init__.py index 10e15438..c9c2ef67 100644 --- a/src/kernel/lib/browser_scoped/__init__.py +++ b/src/kernel/lib/browser_scoped/__init__.py @@ -1,3 +1 @@ -from .client import BrowserScopedClient, AsyncBrowserScopedClient - -__all__ = ["BrowserScopedClient", "AsyncBrowserScopedClient"] +__all__: list[str] = [] diff --git a/src/kernel/lib/browser_scoped/client.py b/src/kernel/lib/browser_scoped/client.py deleted file mode 100644 index 9575bbcb..00000000 --- a/src/kernel/lib/browser_scoped/client.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Browser-scoped view over a session: VM subresources and raw HTTP via internal /curl/raw.""" - -from __future__ import annotations - -from typing import IO, TYPE_CHECKING, Any, Mapping, cast -from contextlib import contextmanager, asynccontextmanager -from collections.abc import Iterable, Iterator, AsyncIterator - -import httpx - -from .util import ( - jwt_from_cdp_ws_url, - sanitize_curl_raw_params, - base_url_from_browser_like, - cdp_ws_url_from_browser_like, - session_id_from_browser_like, -) -from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given -from ..._models import FinalRequestOptions -from .generated_bindings import BrowserScopedFacadeMixin, AsyncBrowserScopedFacadeMixin -from .browser_session_kernel import build_browser_session_kernel, build_async_browser_session_kernel - -if TYPE_CHECKING: - from ..._client import Kernel, AsyncKernel - - -class BrowserScopedClient(BrowserScopedFacadeMixin): - """Session-scoped API: subresources without repeating session id; HTTP via browser /curl/raw.""" - - def __init__(self, parent: Kernel, *, session_id: str, session_base_url: str, jwt: str) -> None: - self._parent = parent - self.session_id = session_id - self._session_base_url = session_base_url - self._jwt = jwt - self._http = build_browser_session_kernel( - parent, session_id=session_id, session_base_url=session_base_url, jwt=jwt - ) - - @property - def parent(self) -> Kernel: - """Control-plane client this view was created from (for future id remapping hooks).""" - return self._parent - - @property - def base_url(self) -> str: - return self._session_base_url - - def request( - self, - method: str, - url: str, - *, - content: BinaryTypes | None = None, - json: Body | None = None, - headers: Mapping[str, str] | None = None, - params: Mapping[str, object] | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - ) -> httpx.Response: - if json is not None and content is not None: - raise TypeError("Passing both `json` and `content` is not supported") - q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} - opts = FinalRequestOptions.construct( - method=method.upper(), - url="/curl/raw", - params=q, - headers=_normalize_headers(headers), - content=_normalize_binary_content(content), - json_data=json, - timeout=_normalize_timeout(timeout), - ) - return cast(httpx.Response, self._http.request(httpx.Response, opts)) - - @contextmanager - def stream( - self, - method: str, - url: str, - *, - content: BinaryTypes | None = None, - headers: Mapping[str, str] | None = None, - params: Mapping[str, object] | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - ) -> Iterator[httpx.Response]: - q: dict[str, Any] = dict(self._http.default_query) - q.update(sanitize_curl_raw_params(params)) - q["url"] = url - h = {k: v for k, v in self._http.default_headers.items() if isinstance(v, str)} - if content is None: - h.pop("Content-Type", None) - if headers: - h.update(headers) - eff_timeout = self._http.timeout if isinstance(timeout, NotGiven) else timeout - cm = self._http._client.stream( - method.upper(), - self._http._prepare_url("/curl/raw"), - params=q, - headers=h, - content=_normalize_binary_content(content), - timeout=_normalize_timeout(eff_timeout), - ) - with cm as resp: - yield resp - - -class AsyncBrowserScopedClient(AsyncBrowserScopedFacadeMixin): - def __init__(self, parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str) -> None: - self._parent = parent - self.session_id = session_id - self._session_base_url = session_base_url - self._jwt = jwt - self._http = build_async_browser_session_kernel( - parent, session_id=session_id, session_base_url=session_base_url, jwt=jwt - ) - - @property - def parent(self) -> AsyncKernel: - return self._parent - - @property - def base_url(self) -> str: - return self._session_base_url - - async def request( - self, - method: str, - url: str, - *, - content: BinaryTypes | None = None, - json: Body | None = None, - headers: Mapping[str, str] | None = None, - params: Mapping[str, object] | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - ) -> httpx.Response: - if json is not None and content is not None: - raise TypeError("Passing both `json` and `content` is not supported") - q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} - opts = FinalRequestOptions.construct( - method=method.upper(), - url="/curl/raw", - params=q, - headers=_normalize_headers(headers), - content=_normalize_binary_content(content), - json_data=json, - timeout=_normalize_timeout(timeout), - ) - return cast(httpx.Response, await self._http.request(httpx.Response, opts)) - - @asynccontextmanager - async def stream( - self, - method: str, - url: str, - *, - content: BinaryTypes | None = None, - headers: Mapping[str, str] | None = None, - params: Mapping[str, object] | None = None, - timeout: float | Timeout | None | NotGiven = not_given, - ) -> AsyncIterator[httpx.Response]: - q: dict[str, Any] = dict(self._http.default_query) - q.update(sanitize_curl_raw_params(params)) - q["url"] = url - h = {k: v for k, v in self._http.default_headers.items() if isinstance(v, str)} - if content is None: - h.pop("Content-Type", None) - if headers: - h.update(headers) - eff_timeout = self._http.timeout if isinstance(timeout, NotGiven) else timeout - async with self._http._client.stream( - method.upper(), - self._http._prepare_url("/curl/raw"), - params=q, - headers=h, - content=_normalize_binary_content(content), - timeout=_normalize_timeout(eff_timeout), - ) as resp: - yield resp - - -def browser_scoped_from_browser(parent: Kernel, browser: Any) -> BrowserScopedClient: - session_id = session_id_from_browser_like(browser) - session_base = base_url_from_browser_like(browser) - if not session_base: - raise ValueError("browser.base_url is required for a browser-scoped client") - jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) - if not jwt: - raise ValueError("could not parse jwt from browser.cdp_ws_url; required for browser session HTTP") - return BrowserScopedClient(parent, session_id=session_id, session_base_url=session_base, jwt=jwt) - - -def async_browser_scoped_from_browser(parent: AsyncKernel, browser: Any) -> AsyncBrowserScopedClient: - session_id = session_id_from_browser_like(browser) - session_base = base_url_from_browser_like(browser) - if not session_base: - raise ValueError("browser.base_url is required for a browser-scoped client") - jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) - if not jwt: - raise ValueError("could not parse jwt from browser.cdp_ws_url; required for browser session HTTP") - return AsyncBrowserScopedClient(parent, session_id=session_id, session_base_url=session_base, jwt=jwt) - - -def _normalize_headers(headers: Mapping[str, str] | None) -> Mapping[str, str]: - return headers if headers is not None else {} - - -def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Timeout | None: - return None if isinstance(timeout, NotGiven) else timeout - - -def _normalize_binary_content(content: BinaryTypes | None) -> bytes | IO[bytes] | Iterable[bytes] | None: - if content is None: - return None - if isinstance(content, bytearray): - return bytes(content) - if isinstance(content, memoryview): - return content.tobytes() - return content diff --git a/src/kernel/lib/browser_scoped/generated_bindings.py b/src/kernel/lib/browser_scoped/generated_bindings.py deleted file mode 100644 index 1a9524c2..00000000 --- a/src/kernel/lib/browser_scoped/generated_bindings.py +++ /dev/null @@ -1,1946 +0,0 @@ -# Code generated by scripts/generate_browser_scoped.py. DO NOT EDIT. -# ruff: noqa: I001, F401 -# pyright: reportUnusedImport=false -# mypy: ignore-errors -"""Browser-scoped wrappers over generated `resources.browsers` classes (AST-driven).""" - -from __future__ import annotations - -from typing import Any - -from ..._compat import cached_property -from .util import ScopedResourceProxy - -from typing import Iterable -from typing_extensions import Literal -import httpx -from kernel._types import Body, Omit, Query, Headers, NoneType, NotGiven, SequenceNotStr, omit, not_given -from kernel._response import ( - BinaryAPIResponse, - AsyncBinaryAPIResponse, - StreamedBinaryAPIResponse, - AsyncStreamedBinaryAPIResponse, - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - to_custom_raw_response_wrapper, - async_to_streamed_response_wrapper, - to_custom_streamed_response_wrapper, - async_to_custom_raw_response_wrapper, - async_to_custom_streamed_response_wrapper, -) -from kernel.types.browsers import ( - computer_batch_params, - computer_scroll_params, - computer_press_key_params, - computer_type_text_params, - computer_drag_mouse_params, - computer_move_mouse_params, - computer_click_mouse_params, - computer_write_clipboard_params, - computer_capture_screenshot_params, - computer_set_cursor_visibility_params, -) -from kernel.types.browsers.computer_read_clipboard_response import ComputerReadClipboardResponse -from kernel.types.browsers.computer_get_mouse_position_response import ComputerGetMousePositionResponse -from kernel.types.browsers.computer_set_cursor_visibility_response import ComputerSetCursorVisibilityResponse -import os -from typing import Mapping, Iterable, cast -from kernel.resources.browsers.fs.watch import ( - WatchResource, - AsyncWatchResource, - WatchResourceWithRawResponse, - AsyncWatchResourceWithRawResponse, - WatchResourceWithStreamingResponse, - AsyncWatchResourceWithStreamingResponse, -) -from kernel._files import read_file_content, async_read_file_content -from kernel._types import ( - Body, - Omit, - Query, - Headers, - NoneType, - NotGiven, - FileTypes, - BinaryTypes, - FileContent, - AsyncBinaryTypes, - omit, - not_given, -) -from kernel.types.browsers import ( - f_move_params, - f_upload_params, - f_file_info_params, - f_read_file_params, - f_list_files_params, - f_upload_zip_params, - f_write_file_params, - f_delete_file_params, - f_create_directory_params, - f_delete_directory_params, - f_download_dir_zip_params, - f_set_file_permissions_params, -) -from kernel.types.browsers.f_file_info_response import FFileInfoResponse -from kernel.types.browsers.f_list_files_response import FListFilesResponse -from kernel._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given -from kernel._response import ( - to_raw_response_wrapper, - to_streamed_response_wrapper, - async_to_raw_response_wrapper, - async_to_streamed_response_wrapper, -) -from kernel._streaming import Stream, AsyncStream -from kernel.types.browsers.fs import watch_start_params -from kernel.types.browsers.fs.watch_start_response import WatchStartResponse -from kernel.types.browsers.fs.watch_events_response import WatchEventsResponse -from kernel._types import Body, Omit, Query, Headers, NotGiven, omit, not_given -from kernel.types.browsers import log_stream_params -from kernel.types.shared.log_event import LogEvent -from kernel.types.browsers import playwright_execute_params -from kernel.types.browsers.playwright_execute_response import PlaywrightExecuteResponse -from typing import Dict, Optional -from kernel._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given -from kernel.types.browsers import ( - process_exec_params, - process_kill_params, - process_spawn_params, - process_stdin_params, - process_resize_params, -) -from kernel.types.browsers.process_exec_response import ProcessExecResponse -from kernel.types.browsers.process_kill_response import ProcessKillResponse -from kernel.types.browsers.process_spawn_response import ProcessSpawnResponse -from kernel.types.browsers.process_stdin_response import ProcessStdinResponse -from kernel.types.browsers.process_resize_response import ProcessResizeResponse -from kernel.types.browsers.process_status_response import ProcessStatusResponse -from kernel.types.browsers.process_stdout_stream_response import ProcessStdoutStreamResponse -from kernel.types.browsers import replay_start_params -from kernel.types.browsers.replay_list_response import ReplayListResponse -from kernel.types.browsers.replay_start_response import ReplayStartResponse - - -class BoundComputerResource(ScopedResourceProxy): - """Session id is injected for browser API methods.""" - - def batch( - self, - *, - actions: Iterable[computer_batch_params.Action], - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.batch( - self._session_id, - actions=actions, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def capture_screenshot( - self, - *, - region: computer_capture_screenshot_params.Region | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BinaryAPIResponse: - return self._inner.capture_screenshot( - self._session_id, - region=region, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def click_mouse( - self, - *, - x: int, - y: int, - button: Literal["left", "right", "middle", "back", "forward"] | Omit = omit, - click_type: Literal["down", "up", "click"] | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - num_clicks: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.click_mouse( - self._session_id, - x=x, - y=y, - button=button, - click_type=click_type, - hold_keys=hold_keys, - num_clicks=num_clicks, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def drag_mouse( - self, - *, - path: Iterable[Iterable[int]], - button: Literal["left", "middle", "right"] | Omit = omit, - delay: int | Omit = omit, - duration_ms: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - smooth: bool | Omit = omit, - step_delay_ms: int | Omit = omit, - steps_per_segment: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.drag_mouse( - self._session_id, - path=path, - button=button, - delay=delay, - duration_ms=duration_ms, - hold_keys=hold_keys, - smooth=smooth, - step_delay_ms=step_delay_ms, - steps_per_segment=steps_per_segment, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def get_mouse_position( - self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ComputerGetMousePositionResponse: - return self._inner.get_mouse_position( - self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def move_mouse( - self, - *, - x: int, - y: int, - duration_ms: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - smooth: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.move_mouse( - self._session_id, - x=x, - y=y, - duration_ms=duration_ms, - hold_keys=hold_keys, - smooth=smooth, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def press_key( - self, - *, - keys: SequenceNotStr[str], - duration: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.press_key( - self._session_id, - keys=keys, - duration=duration, - hold_keys=hold_keys, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def read_clipboard( - self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ComputerReadClipboardResponse: - return self._inner.read_clipboard( - self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def scroll( - self, - *, - x: int, - y: int, - delta_x: int | Omit = omit, - delta_y: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.scroll( - self._session_id, - x=x, - y=y, - delta_x=delta_x, - delta_y=delta_y, - hold_keys=hold_keys, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def set_cursor_visibility( - self, - *, - hidden: bool, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ComputerSetCursorVisibilityResponse: - return self._inner.set_cursor_visibility( - self._session_id, - hidden=hidden, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def type_text( - self, - *, - text: str, - delay: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.type_text( - self._session_id, - text=text, - delay=delay, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def write_clipboard( - self, - *, - text: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.write_clipboard( - self._session_id, - text=text, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundAsyncComputerResource(ScopedResourceProxy): - """Async variant: session id is injected for browser API methods.""" - - async def batch( - self, - *, - actions: Iterable[computer_batch_params.Action], - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.batch( - self._session_id, - actions=actions, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def capture_screenshot( - self, - *, - region: computer_capture_screenshot_params.Region | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncBinaryAPIResponse: - return await self._inner.capture_screenshot( - self._session_id, - region=region, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def click_mouse( - self, - *, - x: int, - y: int, - button: Literal["left", "right", "middle", "back", "forward"] | Omit = omit, - click_type: Literal["down", "up", "click"] | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - num_clicks: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.click_mouse( - self._session_id, - x=x, - y=y, - button=button, - click_type=click_type, - hold_keys=hold_keys, - num_clicks=num_clicks, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def drag_mouse( - self, - *, - path: Iterable[Iterable[int]], - button: Literal["left", "middle", "right"] | Omit = omit, - delay: int | Omit = omit, - duration_ms: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - smooth: bool | Omit = omit, - step_delay_ms: int | Omit = omit, - steps_per_segment: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.drag_mouse( - self._session_id, - path=path, - button=button, - delay=delay, - duration_ms=duration_ms, - hold_keys=hold_keys, - smooth=smooth, - step_delay_ms=step_delay_ms, - steps_per_segment=steps_per_segment, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def get_mouse_position( - self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ComputerGetMousePositionResponse: - return await self._inner.get_mouse_position( - self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def move_mouse( - self, - *, - x: int, - y: int, - duration_ms: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - smooth: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.move_mouse( - self._session_id, - x=x, - y=y, - duration_ms=duration_ms, - hold_keys=hold_keys, - smooth=smooth, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def press_key( - self, - *, - keys: SequenceNotStr[str], - duration: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.press_key( - self._session_id, - keys=keys, - duration=duration, - hold_keys=hold_keys, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def read_clipboard( - self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ComputerReadClipboardResponse: - return await self._inner.read_clipboard( - self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def scroll( - self, - *, - x: int, - y: int, - delta_x: int | Omit = omit, - delta_y: int | Omit = omit, - hold_keys: SequenceNotStr[str] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.scroll( - self._session_id, - x=x, - y=y, - delta_x=delta_x, - delta_y=delta_y, - hold_keys=hold_keys, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def set_cursor_visibility( - self, - *, - hidden: bool, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ComputerSetCursorVisibilityResponse: - return await self._inner.set_cursor_visibility( - self._session_id, - hidden=hidden, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def type_text( - self, - *, - text: str, - delay: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.type_text( - self._session_id, - text=text, - delay=delay, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def write_clipboard( - self, - *, - text: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.write_clipboard( - self._session_id, - text=text, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundLogsResource(ScopedResourceProxy): - """Session id is injected for browser API methods.""" - - def stream( - self, - *, - source: Literal["path", "supervisor"], - follow: bool | Omit = omit, - path: str | Omit = omit, - supervisor_process: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> Stream[LogEvent]: - return self._inner.stream( - self._session_id, - source=source, - follow=follow, - path=path, - supervisor_process=supervisor_process, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundAsyncLogsResource(ScopedResourceProxy): - """Async variant: session id is injected for browser API methods.""" - - async def stream( - self, - *, - source: Literal["path", "supervisor"], - follow: bool | Omit = omit, - path: str | Omit = omit, - supervisor_process: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncStream[LogEvent]: - return await self._inner.stream( - self._session_id, - source=source, - follow=follow, - path=path, - supervisor_process=supervisor_process, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundPlaywrightResource(ScopedResourceProxy): - """Session id is injected for browser API methods.""" - - def execute( - self, - *, - code: str, - timeout_sec: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> PlaywrightExecuteResponse: - return self._inner.execute( - self._session_id, - code=code, - timeout_sec=timeout_sec, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundAsyncPlaywrightResource(ScopedResourceProxy): - """Async variant: session id is injected for browser API methods.""" - - async def execute( - self, - *, - code: str, - timeout_sec: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> PlaywrightExecuteResponse: - return await self._inner.execute( - self._session_id, - code=code, - timeout_sec=timeout_sec, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundProcessResource(ScopedResourceProxy): - """Session id is injected for browser API methods.""" - - def exec( - self, - *, - command: str, - args: SequenceNotStr[str] | Omit = omit, - as_root: bool | Omit = omit, - as_user: Optional[str] | Omit = omit, - cwd: Optional[str] | Omit = omit, - env: Dict[str, str] | Omit = omit, - timeout_sec: Optional[int] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessExecResponse: - return self._inner.exec( - self._session_id, - command=command, - args=args, - as_root=as_root, - as_user=as_user, - cwd=cwd, - env=env, - timeout_sec=timeout_sec, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def kill( - self, - process_id: str, - *, - signal: Literal["TERM", "KILL", "INT", "HUP"], - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessKillResponse: - return self._inner.kill( - process_id, - id=self._session_id, - signal=signal, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def resize( - self, - process_id: str, - *, - cols: int, - rows: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessResizeResponse: - return self._inner.resize( - process_id, - id=self._session_id, - cols=cols, - rows=rows, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def spawn( - self, - *, - command: str, - allocate_tty: bool | Omit = omit, - args: SequenceNotStr[str] | Omit = omit, - as_root: bool | Omit = omit, - as_user: Optional[str] | Omit = omit, - cols: int | Omit = omit, - cwd: Optional[str] | Omit = omit, - env: Dict[str, str] | Omit = omit, - rows: int | Omit = omit, - timeout_sec: Optional[int] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessSpawnResponse: - return self._inner.spawn( - self._session_id, - command=command, - allocate_tty=allocate_tty, - args=args, - as_root=as_root, - as_user=as_user, - cols=cols, - cwd=cwd, - env=env, - rows=rows, - timeout_sec=timeout_sec, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def status( - self, - process_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessStatusResponse: - return self._inner.status( - process_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def stdin( - self, - process_id: str, - *, - data_b64: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessStdinResponse: - return self._inner.stdin( - process_id, - id=self._session_id, - data_b64=data_b64, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def stdout_stream( - self, - process_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> Stream[ProcessStdoutStreamResponse]: - return self._inner.stdout_stream( - process_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundAsyncProcessResource(ScopedResourceProxy): - """Async variant: session id is injected for browser API methods.""" - - async def exec( - self, - *, - command: str, - args: SequenceNotStr[str] | Omit = omit, - as_root: bool | Omit = omit, - as_user: Optional[str] | Omit = omit, - cwd: Optional[str] | Omit = omit, - env: Dict[str, str] | Omit = omit, - timeout_sec: Optional[int] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessExecResponse: - return await self._inner.exec( - self._session_id, - command=command, - args=args, - as_root=as_root, - as_user=as_user, - cwd=cwd, - env=env, - timeout_sec=timeout_sec, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def kill( - self, - process_id: str, - *, - signal: Literal["TERM", "KILL", "INT", "HUP"], - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessKillResponse: - return await self._inner.kill( - process_id, - id=self._session_id, - signal=signal, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def resize( - self, - process_id: str, - *, - cols: int, - rows: int, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessResizeResponse: - return await self._inner.resize( - process_id, - id=self._session_id, - cols=cols, - rows=rows, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def spawn( - self, - *, - command: str, - allocate_tty: bool | Omit = omit, - args: SequenceNotStr[str] | Omit = omit, - as_root: bool | Omit = omit, - as_user: Optional[str] | Omit = omit, - cols: int | Omit = omit, - cwd: Optional[str] | Omit = omit, - env: Dict[str, str] | Omit = omit, - rows: int | Omit = omit, - timeout_sec: Optional[int] | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessSpawnResponse: - return await self._inner.spawn( - self._session_id, - command=command, - allocate_tty=allocate_tty, - args=args, - as_root=as_root, - as_user=as_user, - cols=cols, - cwd=cwd, - env=env, - rows=rows, - timeout_sec=timeout_sec, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def status( - self, - process_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessStatusResponse: - return await self._inner.status( - process_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def stdin( - self, - process_id: str, - *, - data_b64: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ProcessStdinResponse: - return await self._inner.stdin( - process_id, - id=self._session_id, - data_b64=data_b64, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def stdout_stream( - self, - process_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncStream[ProcessStdoutStreamResponse]: - return await self._inner.stdout_stream( - process_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundReplaysResource(ScopedResourceProxy): - """Session id is injected for browser API methods.""" - - def list( - self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReplayListResponse: - return self._inner.list( - self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def download( - self, - replay_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BinaryAPIResponse: - return self._inner.download( - replay_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def start( - self, - *, - framerate: int | Omit = omit, - max_duration_in_seconds: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReplayStartResponse: - return self._inner.start( - self._session_id, - framerate=framerate, - max_duration_in_seconds=max_duration_in_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def stop( - self, - replay_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.stop( - replay_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundAsyncReplaysResource(ScopedResourceProxy): - """Async variant: session id is injected for browser API methods.""" - - async def list( - self, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReplayListResponse: - return await self._inner.list( - self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def download( - self, - replay_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncBinaryAPIResponse: - return await self._inner.download( - replay_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def start( - self, - *, - framerate: int | Omit = omit, - max_duration_in_seconds: int | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> ReplayStartResponse: - return await self._inner.start( - self._session_id, - framerate=framerate, - max_duration_in_seconds=max_duration_in_seconds, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def stop( - self, - replay_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.stop( - replay_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundWatchResource(ScopedResourceProxy): - """Session id is injected for browser API methods.""" - - def events( - self, - watch_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> Stream[WatchEventsResponse]: - return self._inner.events( - watch_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def start( - self, - *, - path: str, - recursive: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> WatchStartResponse: - return self._inner.start( - self._session_id, - path=path, - recursive=recursive, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def stop( - self, - watch_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.stop( - watch_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundAsyncWatchResource(ScopedResourceProxy): - """Async variant: session id is injected for browser API methods.""" - - async def events( - self, - watch_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncStream[WatchEventsResponse]: - return await self._inner.events( - watch_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def start( - self, - *, - path: str, - recursive: bool | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> WatchStartResponse: - return await self._inner.start( - self._session_id, - path=path, - recursive=recursive, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def stop( - self, - watch_id: str, - *, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.stop( - watch_id, - id=self._session_id, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundFsResource(ScopedResourceProxy): - """Session id is injected for browser API methods.""" - - @cached_property - def watch(self) -> BoundWatchResource: - from ...resources.browsers.fs.watch import WatchResource - - return BoundWatchResource(WatchResource(self._inner._client), self._session_id) - - def create_directory( - self, - *, - path: str, - mode: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.create_directory( - self._session_id, - path=path, - mode=mode, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def delete_directory( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.delete_directory( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def delete_file( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.delete_file( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def download_dir_zip( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BinaryAPIResponse: - return self._inner.download_dir_zip( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def file_info( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> FFileInfoResponse: - return self._inner.file_info( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def list_files( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> FListFilesResponse: - return self._inner.list_files( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def move( - self, - *, - dest_path: str, - src_path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.move( - self._session_id, - dest_path=dest_path, - src_path=src_path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def read_file( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> BinaryAPIResponse: - return self._inner.read_file( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def set_file_permissions( - self, - *, - mode: str, - path: str, - group: str | Omit = omit, - owner: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.set_file_permissions( - self._session_id, - mode=mode, - path=path, - group=group, - owner=owner, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def upload( - self, - *, - files: Iterable[f_upload_params.File], - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.upload( - self._session_id, - files=files, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def upload_zip( - self, - *, - dest_path: str, - zip_file: FileTypes, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.upload_zip( - self._session_id, - dest_path=dest_path, - zip_file=zip_file, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - def write_file( - self, - contents: FileContent | BinaryTypes, - *, - path: str, - mode: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return self._inner.write_file( - self._session_id, - contents, - path=path, - mode=mode, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BoundAsyncFsResource(ScopedResourceProxy): - """Async variant: session id is injected for browser API methods.""" - - @cached_property - def watch(self) -> BoundAsyncWatchResource: - from ...resources.browsers.fs.watch import AsyncWatchResource - - return BoundAsyncWatchResource(AsyncWatchResource(self._inner._client), self._session_id) - - async def create_directory( - self, - *, - path: str, - mode: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.create_directory( - self._session_id, - path=path, - mode=mode, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def delete_directory( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.delete_directory( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def delete_file( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.delete_file( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def download_dir_zip( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncBinaryAPIResponse: - return await self._inner.download_dir_zip( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def file_info( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> FFileInfoResponse: - return await self._inner.file_info( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def list_files( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> FListFilesResponse: - return await self._inner.list_files( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def move( - self, - *, - dest_path: str, - src_path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.move( - self._session_id, - dest_path=dest_path, - src_path=src_path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def read_file( - self, - *, - path: str, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> AsyncBinaryAPIResponse: - return await self._inner.read_file( - self._session_id, - path=path, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def set_file_permissions( - self, - *, - mode: str, - path: str, - group: str | Omit = omit, - owner: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.set_file_permissions( - self._session_id, - mode=mode, - path=path, - group=group, - owner=owner, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def upload( - self, - *, - files: Iterable[f_upload_params.File], - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.upload( - self._session_id, - files=files, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def upload_zip( - self, - *, - dest_path: str, - zip_file: FileTypes, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.upload_zip( - self._session_id, - dest_path=dest_path, - zip_file=zip_file, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - async def write_file( - self, - contents: FileContent | AsyncBinaryTypes, - *, - path: str, - mode: str | Omit = omit, - extra_headers: Headers | None = None, - extra_query: Query | None = None, - extra_body: Body | None = None, - timeout: float | httpx.Timeout | None | NotGiven = not_given, - ) -> None: - return await self._inner.write_file( - self._session_id, - contents, - path=path, - mode=mode, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, - timeout=timeout, - ) - - -class BrowserScopedFacadeMixin: - """Top-level browser session subresources (sync); uses `_http` and `session_id`.""" - - _http: Any - session_id: str - - @cached_property - def computer(self) -> BoundComputerResource: - from ...resources.browsers.computer import ComputerResource - - return BoundComputerResource(ComputerResource(self._http), self.session_id) - - @cached_property - def fs(self) -> BoundFsResource: - from ...resources.browsers.fs.fs import FsResource - - return BoundFsResource(FsResource(self._http), self.session_id) - - @cached_property - def logs(self) -> BoundLogsResource: - from ...resources.browsers.logs import LogsResource - - return BoundLogsResource(LogsResource(self._http), self.session_id) - - @cached_property - def playwright(self) -> BoundPlaywrightResource: - from ...resources.browsers.playwright import PlaywrightResource - - return BoundPlaywrightResource(PlaywrightResource(self._http), self.session_id) - - @cached_property - def process(self) -> BoundProcessResource: - from ...resources.browsers.process import ProcessResource - - return BoundProcessResource(ProcessResource(self._http), self.session_id) - - @cached_property - def replays(self) -> BoundReplaysResource: - from ...resources.browsers.replays import ReplaysResource - - return BoundReplaysResource(ReplaysResource(self._http), self.session_id) - - -class AsyncBrowserScopedFacadeMixin: - """Top-level browser session subresources (async); uses `_http` and `session_id`.""" - - _http: Any - session_id: str - - @cached_property - def computer(self) -> BoundAsyncComputerResource: - from ...resources.browsers.computer import AsyncComputerResource - - return BoundAsyncComputerResource(AsyncComputerResource(self._http), self.session_id) - - @cached_property - def fs(self) -> BoundAsyncFsResource: - from ...resources.browsers.fs.fs import AsyncFsResource - - return BoundAsyncFsResource(AsyncFsResource(self._http), self.session_id) - - @cached_property - def logs(self) -> BoundAsyncLogsResource: - from ...resources.browsers.logs import AsyncLogsResource - - return BoundAsyncLogsResource(AsyncLogsResource(self._http), self.session_id) - - @cached_property - def playwright(self) -> BoundAsyncPlaywrightResource: - from ...resources.browsers.playwright import AsyncPlaywrightResource - - return BoundAsyncPlaywrightResource(AsyncPlaywrightResource(self._http), self.session_id) - - @cached_property - def process(self) -> BoundAsyncProcessResource: - from ...resources.browsers.process import AsyncProcessResource - - return BoundAsyncProcessResource(AsyncProcessResource(self._http), self.session_id) - - @cached_property - def replays(self) -> BoundAsyncReplaysResource: - from ...resources.browsers.replays import AsyncReplaysResource - - return BoundAsyncReplaysResource(AsyncReplaysResource(self._http), self.session_id) - - -__all__ = [ - "BoundAsyncComputerResource", - "BoundAsyncFsResource", - "BoundAsyncLogsResource", - "BoundAsyncPlaywrightResource", - "BoundAsyncProcessResource", - "BoundAsyncReplaysResource", - "BoundAsyncWatchResource", - "BoundComputerResource", - "BoundFsResource", - "BoundLogsResource", - "BoundPlaywrightResource", - "BoundProcessResource", - "BoundReplaysResource", - "BoundWatchResource", -] diff --git a/src/kernel/lib/browser_scoped/raw_http.py b/src/kernel/lib/browser_scoped/raw_http.py new file mode 100644 index 00000000..ba28bff1 --- /dev/null +++ b/src/kernel/lib/browser_scoped/raw_http.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager, contextmanager +from typing import IO, Any, Mapping, cast +from collections.abc import AsyncIterator, Iterable, Iterator + +import httpx + +from ..._models import FinalRequestOptions +from ..._types import Body, BinaryTypes, NotGiven, Timeout, not_given +from .routing import BrowserRoute +from .util import sanitize_curl_raw_params + + +def request_via_browser_route( + parent: Any, + route: BrowserRoute, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + json: Body | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, +) -> httpx.Response: + if json is not None and content is not None: + raise TypeError("Passing both `json` and `content` is not supported") + q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} + if route.jwt: + q["jwt"] = route.jwt + opts = FinalRequestOptions.construct( + method=method.upper(), + url=route.base_url.rstrip("/") + "/curl/raw", + params=q, + headers=headers or {}, + content=_normalize_binary_content(content), + json_data=json, + timeout=timeout, + ) + return cast(httpx.Response, parent.request(httpx.Response, opts)) + + +@contextmanager +def stream_via_browser_route( + parent: Any, + route: BrowserRoute, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, +) -> Iterator[httpx.Response]: + q: dict[str, Any] = sanitize_curl_raw_params(params) + if route.jwt: + q["jwt"] = route.jwt + q["url"] = url + h = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} + if content is None: + h.pop("Content-Type", None) + if headers: + h.update(headers) + h.pop("Authorization", None) + eff_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout + with parent._client.stream( + method.upper(), + route.base_url.rstrip("/") + "/curl/raw", + params=q, + headers=h, + content=_normalize_binary_content(content), + timeout=_normalize_timeout(eff_timeout), + ) as resp: + yield resp + + +async def async_request_via_browser_route( + parent: Any, + route: BrowserRoute, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + json: Body | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, +) -> httpx.Response: + if json is not None and content is not None: + raise TypeError("Passing both `json` and `content` is not supported") + q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} + if route.jwt: + q["jwt"] = route.jwt + opts = FinalRequestOptions.construct( + method=method.upper(), + url=route.base_url.rstrip("/") + "/curl/raw", + params=q, + headers=headers or {}, + content=_normalize_binary_content(content), + json_data=json, + timeout=timeout, + ) + return cast(httpx.Response, await parent.request(httpx.Response, opts)) + + +@asynccontextmanager +async def async_stream_via_browser_route( + parent: Any, + route: BrowserRoute, + method: str, + url: str, + *, + content: BinaryTypes | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | Timeout | None | NotGiven = not_given, +) -> AsyncIterator[httpx.Response]: + q: dict[str, Any] = sanitize_curl_raw_params(params) + if route.jwt: + q["jwt"] = route.jwt + q["url"] = url + h = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} + if content is None: + h.pop("Content-Type", None) + if headers: + h.update(headers) + h.pop("Authorization", None) + eff_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout + async with parent._client.stream( + method.upper(), + route.base_url.rstrip("/") + "/curl/raw", + params=q, + headers=h, + content=_normalize_binary_content(content), + timeout=_normalize_timeout(eff_timeout), + ) as resp: + yield resp + + +def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Timeout | None: + return None if isinstance(timeout, NotGiven) else timeout + + +def _normalize_binary_content(content: BinaryTypes | None) -> bytes | IO[bytes] | Iterable[bytes] | None: + if content is None: + return None + if isinstance(content, bytearray): + return bytes(content) + if isinstance(content, memoryview): + return content.tobytes() + return content diff --git a/src/kernel/lib/browser_scoped/routing.py b/src/kernel/lib/browser_scoped/routing.py new file mode 100644 index 00000000..691da552 --- /dev/null +++ b/src/kernel/lib/browser_scoped/routing.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, cast + +import httpx + +from ..._compat import model_copy +from ..._models import FinalRequestOptions +from ..._types import Headers +from .util import base_url_from_browser_like, jwt_from_cdp_ws_url, cdp_ws_url_from_browser_like, session_id_from_browser_like + + +@dataclass +class BrowserRoute: + session_id: str + base_url: str + jwt: str | None = None + + +@dataclass +class BrowserRoutingConfig: + enabled: bool = False + direct_to_vm_subresources: tuple[str, ...] = field(default_factory=tuple) + + +class BrowserRouteCache: + def __init__(self) -> None: + self._routes: dict[str, BrowserRoute] = {} + + def get(self, session_id: str) -> BrowserRoute | None: + return self._routes.get(session_id) + + def set(self, route: BrowserRoute) -> None: + self._routes[route.session_id] = BrowserRoute( + session_id=route.session_id.strip(), + base_url=route.base_url.strip().rstrip("/") + "/", + jwt=route.jwt.strip() if isinstance(route.jwt, str) and route.jwt.strip() else None, + ) + + def delete(self, session_id: str) -> None: + self._routes.pop(session_id, None) + + def prime(self, browser: Any) -> BrowserRoute: + session_id = session_id_from_browser_like(browser) + base_url = base_url_from_browser_like(browser) + if not base_url: + raise ValueError("browser.base_url is required to prime the browser route cache") + jwt = None + try: + jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) + except Exception: + jwt = None + route = BrowserRoute(session_id=session_id, base_url=base_url, jwt=jwt) + self.set(route) + return route + + def values(self) -> list[BrowserRoute]: + return list(self._routes.values()) + + +def rewrite_direct_vm_options( + options: FinalRequestOptions, + *, + cache: BrowserRouteCache, + config: BrowserRoutingConfig | None, +) -> FinalRequestOptions: + if config is None or not config.enabled: + return options + + match = match_direct_vm_path(options.url) + if match is None: + return options + + session_id, subresource, suffix = match + if subresource not in set(config.direct_to_vm_subresources): + return options + + route = cache.get(session_id) + if route is None: + return options + + rewritten = model_copy(options) + rewritten.url = f"{route.base_url.rstrip('/')}/{subresource}{suffix}" + + params: dict[str, object] = {} + if isinstance(options.params, Mapping): + params.update(cast(Mapping[str, object], options.params)) + if route.jwt: + params["jwt"] = route.jwt + rewritten.params = params or options.params + return rewritten + + +def strip_direct_vm_auth(request: httpx.Request, *, cache: BrowserRouteCache) -> None: + raw = str(request.url) + for route in cache.values(): + if raw.startswith(route.base_url.rstrip("/") + "/"): + request.headers.pop("Authorization", None) + return + + +def match_direct_vm_path(path: str) -> tuple[str, str, str] | None: + if "://" in path: + return None + + parts = [part for part in path.strip("/").split("/") if part] + for index in range(len(parts) - 2): + if parts[index] != "browsers": + continue + session_id = parts[index + 1] + subresource = parts[index + 2] + if not session_id or not subresource: + return None + suffix = "" + if index + 3 < len(parts): + suffix = "/" + "/".join(parts[index + 3 :]) + return session_id, subresource, suffix + return None + + +def build_direct_vm_headers(headers: Mapping[str, str] | None) -> Headers | None: + if headers is None: + return {"Authorization": None} + out: dict[str, str | None] = {"Authorization": None} + out.update(headers) + return out diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index 228e653a..d4c89a68 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -2,8 +2,9 @@ from __future__ import annotations +from contextlib import asynccontextmanager, contextmanager import typing_extensions -from typing import Dict, Mapping, Iterable, Optional, cast +from typing import AsyncIterator, Dict, Iterator, Mapping, Iterable, Optional, cast from typing_extensions import Literal import httpx @@ -87,6 +88,12 @@ from ...types.shared_params.browser_profile import BrowserProfile from ...types.shared_params.browser_viewport import BrowserViewport from ...types.shared_params.browser_extension import BrowserExtension +from ...lib.browser_scoped.raw_http import ( + async_request_via_browser_route, + async_stream_via_browser_route, + request_via_browser_route, + stream_via_browser_route, +) __all__ = ["BrowsersResource", "AsyncBrowsersResource"] @@ -219,7 +226,7 @@ def create( timeout: Override the client-level default timeout for this request, in seconds """ - return self._post( + result = self._post( "/browsers", body=maybe_transform( { @@ -242,6 +249,8 @@ def create( ), cast_to=BrowserCreateResponse, ) + self._client.prime_browser_route_cache(result) + return result def retrieve( self, @@ -271,7 +280,7 @@ def retrieve( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._get( + result = self._get( path_template("/browsers/{id}", id=id), options=make_request_options( extra_headers=extra_headers, @@ -284,6 +293,8 @@ def retrieve( ), cast_to=BrowserRetrieveResponse, ) + self._client.prime_browser_route_cache(result) + return result def update( self, @@ -325,7 +336,7 @@ def update( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return self._patch( + result = self._patch( path_template("/browsers/{id}", id=id), body=maybe_transform( { @@ -341,6 +352,8 @@ def update( ), cast_to=BrowserUpdateResponse, ) + self._client.prime_browser_route_cache(result) + return result def list( self, @@ -383,7 +396,7 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get_api_list( + page = self._get_api_list( "/browsers", page=SyncOffsetPagination[BrowserListResponse], options=make_request_options( @@ -404,6 +417,9 @@ def list( ), model=BrowserListResponse, ) + for item in page.items: + self._client.prime_browser_route_cache(item) + return page @typing_extensions.deprecated("deprecated") def delete( @@ -510,6 +526,64 @@ def curl( cast_to=BrowserCurlResponse, ) + def request( + self, + id: str, + method: str, + url: str, + *, + content: bytes | bytearray | memoryview | str | Iterable[bytes] | None = None, + json: Body | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> httpx.Response: + route = self._client.browser_route_cache.get(id) + if route is None: + raise ValueError( + f"browser route cache does not contain session {id}; create, retrieve, or list the browser before calling browsers.request" + ) + return request_via_browser_route( + self._client, + route, + method, + url, + content=content, + json=json, + headers=headers, + params=params, + timeout=timeout, + ) + + @contextmanager + def stream( + self, + id: str, + method: str, + url: str, + *, + content: bytes | bytearray | memoryview | str | Iterable[bytes] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> Iterator[httpx.Response]: + route = self._client.browser_route_cache.get(id) + if route is None: + raise ValueError( + f"browser route cache does not contain session {id}; create, retrieve, or list the browser before calling browsers.stream" + ) + with stream_via_browser_route( + self._client, + route, + method, + url, + content=content, + headers=headers, + params=params, + timeout=timeout, + ) as resp: + yield resp + def delete_by_id( self, id: str, @@ -719,7 +793,7 @@ async def create( timeout: Override the client-level default timeout for this request, in seconds """ - return await self._post( + result = await self._post( "/browsers", body=await async_maybe_transform( { @@ -742,6 +816,8 @@ async def create( ), cast_to=BrowserCreateResponse, ) + self._client.prime_browser_route_cache(result) + return result async def retrieve( self, @@ -771,7 +847,7 @@ async def retrieve( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._get( + result = await self._get( path_template("/browsers/{id}", id=id), options=make_request_options( extra_headers=extra_headers, @@ -784,6 +860,8 @@ async def retrieve( ), cast_to=BrowserRetrieveResponse, ) + self._client.prime_browser_route_cache(result) + return result async def update( self, @@ -825,7 +903,7 @@ async def update( """ if not id: raise ValueError(f"Expected a non-empty value for `id` but received {id!r}") - return await self._patch( + result = await self._patch( path_template("/browsers/{id}", id=id), body=await async_maybe_transform( { @@ -841,6 +919,8 @@ async def update( ), cast_to=BrowserUpdateResponse, ) + self._client.prime_browser_route_cache(result) + return result def list( self, @@ -1012,6 +1092,64 @@ async def curl( cast_to=BrowserCurlResponse, ) + async def request( + self, + id: str, + method: str, + url: str, + *, + content: bytes | bytearray | memoryview | str | Iterable[bytes] | None = None, + json: Body | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> httpx.Response: + route = self._client.browser_route_cache.get(id) + if route is None: + raise ValueError( + f"browser route cache does not contain session {id}; create, retrieve, or list the browser before calling browsers.request" + ) + return await async_request_via_browser_route( + self._client, + route, + method, + url, + content=content, + json=json, + headers=headers, + params=params, + timeout=timeout, + ) + + @asynccontextmanager + async def stream( + self, + id: str, + method: str, + url: str, + *, + content: bytes | bytearray | memoryview | str | Iterable[bytes] | None = None, + headers: Mapping[str, str] | None = None, + params: Mapping[str, object] | None = None, + timeout: float | httpx.Timeout | None | NotGiven = not_given, + ) -> AsyncIterator[httpx.Response]: + route = self._client.browser_route_cache.get(id) + if route is None: + raise ValueError( + f"browser route cache does not contain session {id}; create, retrieve, or list the browser before calling browsers.stream" + ) + async with async_stream_via_browser_route( + self._client, + route, + method, + url, + content=content, + headers=headers, + params=params, + timeout=timeout, + ) as resp: + yield resp + async def delete_by_id( self, id: str, diff --git a/tests/test_browser_scoped.py b/tests/test_browser_scoped.py index 5bbdafb5..2681addc 100644 --- a/tests/test_browser_scoped.py +++ b/tests/test_browser_scoped.py @@ -9,6 +9,7 @@ import pytest from kernel import Kernel +from kernel.lib.browser_scoped.routing import BrowserRoutingConfig from kernel.lib.browser_scoped.util import jwt_from_cdp_ws_url base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") @@ -33,7 +34,58 @@ def test_jwt_from_cdp_ws_url() -> None: @respx.mock -def test_for_browser_process_exec_routes_to_session_base() -> None: +def test_main_client_routes_allowlisted_browser_subresources_directly_to_vm() -> None: + route = respx.post("http://browser-session.test/browser/kernel/process/exec").mock( + return_value=httpx.Response( + 200, + json={ + "exit_code": 0, + "stdout_b64": "", + "stderr_b64": "", + }, + ) + ) + with Kernel( + base_url=base_url, + api_key=api_key, + browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",)), + _strict_response_validation=True, + ) as client: + client.prime_browser_route_cache(_fake_browser()) + out = client.browsers.process.exec("sess-1", command="echo", args=["hi"]) + assert route.called + call = cast(Any, route.calls[0]) + request = cast(httpx.Request, call.request) + assert request.url.params.get("jwt") == "token-abc" + assert request.headers.get("Authorization") is None + assert out.exit_code == 0 + + +@respx.mock +def test_main_client_skips_direct_vm_routing_outside_allowlist() -> None: + route = respx.post(f"{base_url}/browsers/sess-1/process/exec").mock( + return_value=httpx.Response( + 200, + json={ + "exit_code": 0, + "stdout_b64": "", + "stderr_b64": "", + }, + ) + ) + with Kernel( + base_url=base_url, + api_key=api_key, + browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("computer",)), + _strict_response_validation=True, + ) as client: + client.prime_browser_route_cache(_fake_browser()) + client.browsers.process.exec("sess-1", command="echo", args=["hi"]) + assert route.called + + +@respx.mock +def test_browser_process_exec_uses_session_id_with_cache_primed() -> None: route = respx.post("http://browser-session.test/browser/kernel/process/exec?jwt=token-abc").mock( return_value=httpx.Response( 200, @@ -44,9 +96,14 @@ def test_for_browser_process_exec_routes_to_session_base() -> None: }, ) ) - with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - b = client.for_browser(_fake_browser()) - out = b.process.exec(command="echo", args=["hi"]) + with Kernel( + base_url=base_url, + api_key=api_key, + browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",)), + _strict_response_validation=True, + ) as client: + client.prime_browser_route_cache(_fake_browser()) + out = client.browsers.process.exec("sess-1", command="echo", args=["hi"]) assert route.called call = cast(Any, route.calls[0]) request = cast(httpx.Request, call.request) @@ -63,8 +120,8 @@ def test_browser_request_uses_curl_raw() -> None: return_value=httpx.Response(200, content=b"ok") ) with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - b = client.for_browser(_fake_browser()) - r = b.request("GET", "https://example.com", params={"timeout_ms": 5000}) + client.prime_browser_route_cache(_fake_browser()) + r = client.browsers.request("sess-1", "GET", "https://example.com", params={"timeout_ms": 5000}) assert r.status_code == 200 assert r.content == b"ok" assert route.called @@ -80,8 +137,9 @@ def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: return_value=httpx.Response(200, content=b"ok") ) with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - b = client.for_browser(_fake_browser()) - b.request( + client.prime_browser_route_cache(_fake_browser()) + client.browsers.request( + "sess-1", "GET", "https://example.com", params={"url": "https://evil.example", "jwt": "other", "timeout_ms": 1}, @@ -95,14 +153,23 @@ def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: assert str(req_url.params.get("timeout_ms")) == "1" +def test_browser_request_requires_cached_route() -> None: + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + client.prime_browser_route_cache(_fake_browser()) + client.browser_route_cache.delete("sess-1") + with pytest.raises(ValueError, match="route cache"): + client.browsers.request("sess-1", "GET", "https://example.com") + + @respx.mock def test_browser_stream_params_cannot_override_target_url_or_jwt() -> None: route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( return_value=httpx.Response(200, content=b"streamed") ) with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - b = client.for_browser(_fake_browser()) - with b.stream( + client.prime_browser_route_cache(_fake_browser()) + with client.browsers.stream( + "sess-1", "GET", "https://example.com", params={"url": "https://evil.example", "jwt": "other"}, @@ -123,14 +190,14 @@ def test_browser_stream_reads_body() -> None: return_value=httpx.Response(200, content=b"streamed") ) with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - b = client.for_browser(_fake_browser()) - with b.stream("GET", "https://example.com") as resp: + client.prime_browser_route_cache(_fake_browser()) + with client.browsers.stream("sess-1", "GET", "https://example.com") as resp: assert resp.status_code == 200 assert resp.read() == b"streamed" -def test_for_browser_requires_base_url() -> None: +def test_prime_browser_route_cache_requires_base_url() -> None: bad = {**_fake_browser(), "base_url": None} with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: with pytest.raises(ValueError, match="base_url"): - client.for_browser(bad) + client.prime_browser_route_cache(bad) From de0476fc043df48a58dd4067bb4b3c0fe7a83f0e Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 11:43:34 -0400 Subject: [PATCH 12/17] refactor: simplify browser routing cache Remove the public cache priming helpers, keep jwt-required routes, and rename the example and tests so the python browser routing diff stays focused on cache-backed direct-to-VM behavior. Made-with: Cursor --- .../{browser_scoped.py => browser_routing.py} | 2 - src/kernel/_client.py | 6 - src/kernel/lib/browser_scoped/raw_http.py | 12 +- src/kernel/lib/browser_scoped/routing.py | 45 ++-- src/kernel/lib/browser_scoped/util.py | 27 --- src/kernel/resources/browsers/browsers.py | 29 ++- tests/test_browser_routing.py | 125 +++++++++++ tests/test_browser_scoped.py | 203 ------------------ 8 files changed, 171 insertions(+), 278 deletions(-) rename examples/{browser_scoped.py => browser_routing.py} (93%) create mode 100644 tests/test_browser_routing.py delete mode 100644 tests/test_browser_scoped.py diff --git a/examples/browser_scoped.py b/examples/browser_routing.py similarity index 93% rename from examples/browser_scoped.py rename to examples/browser_routing.py index 81227dbd..e987fc0a 100644 --- a/examples/browser_scoped.py +++ b/examples/browser_routing.py @@ -7,8 +7,6 @@ def main() -> None: with Kernel(browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",))) as client: browser = client.browsers.create(headless=True) try: - client.prime_browser_route_cache(browser) - client.browsers.process.exec(browser.session_id, command="uname", args=["-a"]) response = client.browsers.request(browser.session_id, "GET", "https://example.com") diff --git a/src/kernel/_client.py b/src/kernel/_client.py index 1356a490..8563715f 100644 --- a/src/kernel/_client.py +++ b/src/kernel/_client.py @@ -343,9 +343,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def prime_browser_route_cache(self, browser: Any) -> None: - self.browser_route_cache.prime(browser) - @override def _make_status_error( self, @@ -640,9 +637,6 @@ def copy( # client.with_options(timeout=10).foo.create(...) with_options = copy - def prime_browser_route_cache(self, browser: Any) -> None: - self.browser_route_cache.prime(browser) - @override def _make_status_error( self, diff --git a/src/kernel/lib/browser_scoped/raw_http.py b/src/kernel/lib/browser_scoped/raw_http.py index ba28bff1..13aa5c12 100644 --- a/src/kernel/lib/browser_scoped/raw_http.py +++ b/src/kernel/lib/browser_scoped/raw_http.py @@ -27,8 +27,7 @@ def request_via_browser_route( if json is not None and content is not None: raise TypeError("Passing both `json` and `content` is not supported") q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} - if route.jwt: - q["jwt"] = route.jwt + q["jwt"] = route.jwt opts = FinalRequestOptions.construct( method=method.upper(), url=route.base_url.rstrip("/") + "/curl/raw", @@ -54,8 +53,7 @@ def stream_via_browser_route( timeout: float | Timeout | None | NotGiven = not_given, ) -> Iterator[httpx.Response]: q: dict[str, Any] = sanitize_curl_raw_params(params) - if route.jwt: - q["jwt"] = route.jwt + q["jwt"] = route.jwt q["url"] = url h = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} if content is None: @@ -90,8 +88,7 @@ async def async_request_via_browser_route( if json is not None and content is not None: raise TypeError("Passing both `json` and `content` is not supported") q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} - if route.jwt: - q["jwt"] = route.jwt + q["jwt"] = route.jwt opts = FinalRequestOptions.construct( method=method.upper(), url=route.base_url.rstrip("/") + "/curl/raw", @@ -117,8 +114,7 @@ async def async_stream_via_browser_route( timeout: float | Timeout | None | NotGiven = not_given, ) -> AsyncIterator[httpx.Response]: q: dict[str, Any] = sanitize_curl_raw_params(params) - if route.jwt: - q["jwt"] = route.jwt + q["jwt"] = route.jwt q["url"] = url h = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} if content is None: diff --git a/src/kernel/lib/browser_scoped/routing.py b/src/kernel/lib/browser_scoped/routing.py index 691da552..8f13a4a6 100644 --- a/src/kernel/lib/browser_scoped/routing.py +++ b/src/kernel/lib/browser_scoped/routing.py @@ -7,7 +7,6 @@ from ..._compat import model_copy from ..._models import FinalRequestOptions -from ..._types import Headers from .util import base_url_from_browser_like, jwt_from_cdp_ws_url, cdp_ws_url_from_browser_like, session_id_from_browser_like @@ -15,7 +14,7 @@ class BrowserRoute: session_id: str base_url: str - jwt: str | None = None + jwt: str @dataclass @@ -35,30 +34,33 @@ def set(self, route: BrowserRoute) -> None: self._routes[route.session_id] = BrowserRoute( session_id=route.session_id.strip(), base_url=route.base_url.strip().rstrip("/") + "/", - jwt=route.jwt.strip() if isinstance(route.jwt, str) and route.jwt.strip() else None, + jwt=route.jwt.strip(), ) def delete(self, session_id: str) -> None: self._routes.pop(session_id, None) - def prime(self, browser: Any) -> BrowserRoute: - session_id = session_id_from_browser_like(browser) - base_url = base_url_from_browser_like(browser) - if not base_url: - raise ValueError("browser.base_url is required to prime the browser route cache") - jwt = None - try: - jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) - except Exception: - jwt = None - route = BrowserRoute(session_id=session_id, base_url=base_url, jwt=jwt) - self.set(route) - return route - def values(self) -> list[BrowserRoute]: return list(self._routes.values()) +def browser_route_from_browser(browser: Any) -> BrowserRoute | None: + session_id = session_id_from_browser_like(browser) + base_url = base_url_from_browser_like(browser) + if not base_url: + return None + + jwt = None + try: + jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser)) + except Exception: + jwt = None + if not jwt: + return None + + return BrowserRoute(session_id=session_id, base_url=base_url, jwt=jwt) + + def rewrite_direct_vm_options( options: FinalRequestOptions, *, @@ -86,8 +88,7 @@ def rewrite_direct_vm_options( params: dict[str, object] = {} if isinstance(options.params, Mapping): params.update(cast(Mapping[str, object], options.params)) - if route.jwt: - params["jwt"] = route.jwt + params["jwt"] = route.jwt rewritten.params = params or options.params return rewritten @@ -119,9 +120,3 @@ def match_direct_vm_path(path: str) -> tuple[str, str, str] | None: return None -def build_direct_vm_headers(headers: Mapping[str, str] | None) -> Headers | None: - if headers is None: - return {"Authorization": None} - out: dict[str, str | None] = {"Authorization": None} - out.update(headers) - return out diff --git a/src/kernel/lib/browser_scoped/util.py b/src/kernel/lib/browser_scoped/util.py index bddb6dd1..876fa0e6 100644 --- a/src/kernel/lib/browser_scoped/util.py +++ b/src/kernel/lib/browser_scoped/util.py @@ -1,6 +1,5 @@ from __future__ import annotations -import inspect from typing import Any, Mapping, cast from urllib.parse import parse_qs, urlparse @@ -59,29 +58,3 @@ def cdp_ws_url_from_browser_like(browser: Any) -> str: raise TypeError("browser object must have a non-empty cdp_ws_url") -class ScopedResourceProxy: - """Delegates to a generated resource; injects `id` for callables that still expose it.""" - - def __init__(self, inner: Any, session_id: str) -> None: - object.__setattr__(self, "_inner", inner) - object.__setattr__(self, "_session_id", session_id) - - def __getattr__(self, name: str) -> Any: - if name.startswith("_"): - raise AttributeError(name) - attr = getattr(self._inner, name) - if name.startswith("with_") or not callable(attr): - return attr - try: - sig = inspect.signature(attr) - except (TypeError, ValueError): - return attr - if "id" not in sig.parameters: - return attr - - def bound(*args: Any, **kwargs: Any) -> Any: - kw = dict(kwargs) - kw["id"] = self._session_id - return attr(*args, **kw) - - return bound diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index d4c89a68..70bc6635 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -94,6 +94,7 @@ request_via_browser_route, stream_via_browser_route, ) +from ...lib.browser_scoped.routing import browser_route_from_browser __all__ = ["BrowsersResource", "AsyncBrowsersResource"] @@ -249,7 +250,9 @@ def create( ), cast_to=BrowserCreateResponse, ) - self._client.prime_browser_route_cache(result) + route = browser_route_from_browser(result) + if route is not None: + self._client.browser_route_cache.set(route) return result def retrieve( @@ -293,7 +296,9 @@ def retrieve( ), cast_to=BrowserRetrieveResponse, ) - self._client.prime_browser_route_cache(result) + route = browser_route_from_browser(result) + if route is not None: + self._client.browser_route_cache.set(route) return result def update( @@ -352,7 +357,9 @@ def update( ), cast_to=BrowserUpdateResponse, ) - self._client.prime_browser_route_cache(result) + route = browser_route_from_browser(result) + if route is not None: + self._client.browser_route_cache.set(route) return result def list( @@ -418,7 +425,9 @@ def list( model=BrowserListResponse, ) for item in page.items: - self._client.prime_browser_route_cache(item) + route = browser_route_from_browser(item) + if route is not None: + self._client.browser_route_cache.set(route) return page @typing_extensions.deprecated("deprecated") @@ -816,7 +825,9 @@ async def create( ), cast_to=BrowserCreateResponse, ) - self._client.prime_browser_route_cache(result) + route = browser_route_from_browser(result) + if route is not None: + self._client.browser_route_cache.set(route) return result async def retrieve( @@ -860,7 +871,9 @@ async def retrieve( ), cast_to=BrowserRetrieveResponse, ) - self._client.prime_browser_route_cache(result) + route = browser_route_from_browser(result) + if route is not None: + self._client.browser_route_cache.set(route) return result async def update( @@ -919,7 +932,9 @@ async def update( ), cast_to=BrowserUpdateResponse, ) - self._client.prime_browser_route_cache(result) + route = browser_route_from_browser(result) + if route is not None: + self._client.browser_route_cache.set(route) return result def list( diff --git a/tests/test_browser_routing.py b/tests/test_browser_routing.py new file mode 100644 index 00000000..83cfe361 --- /dev/null +++ b/tests/test_browser_routing.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import os +from typing import Any, cast + +import httpx +import pytest +import respx + +from kernel import Kernel +from kernel.lib.browser_scoped.routing import BrowserRoutingConfig, browser_route_from_browser +from kernel.lib.browser_scoped.util import jwt_from_cdp_ws_url + +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +api_key = "sk-123" + + +def _fake_browser() -> dict[str, object]: + return { + "session_id": "sess-1", + "base_url": "http://browser-session.test/browser/kernel", + "cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=token-abc", + "webdriver_ws_url": "wss://x", + "created_at": "2020-01-01T00:00:00Z", + "headless": True, + "stealth": False, + "timeout_seconds": 60, + } + + +def _cache_browser(client: Kernel) -> None: + route = browser_route_from_browser(_fake_browser()) + assert route is not None + client.browser_route_cache.set(route) + + +def test_jwt_from_cdp_ws_url() -> None: + assert jwt_from_cdp_ws_url("wss://h/browser/cdp?jwt=abc%2Fdef&x=1") == "abc/def" + + +@respx.mock +def test_routes_allowlisted_browser_subresources_directly_to_vm() -> None: + route = respx.post("http://browser-session.test/browser/kernel/process/exec").mock( + return_value=httpx.Response(200, json={"exit_code": 0, "stdout_b64": "", "stderr_b64": ""}) + ) + with Kernel( + base_url=base_url, + api_key=api_key, + browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",)), + _strict_response_validation=True, + ) as client: + _cache_browser(client) + out = client.browsers.process.exec("sess-1", command="echo", args=["hi"]) + + assert route.called + request = cast(httpx.Request, cast(Any, route.calls[0]).request) + assert request.url.params.get("jwt") == "token-abc" + assert request.headers.get("Authorization") is None + assert out.exit_code == 0 + + +@respx.mock +def test_skips_direct_vm_routing_outside_allowlist() -> None: + route = respx.post(f"{base_url}/browsers/sess-1/process/exec").mock( + return_value=httpx.Response(200, json={"exit_code": 0, "stdout_b64": "", "stderr_b64": ""}) + ) + with Kernel( + base_url=base_url, + api_key=api_key, + browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("computer",)), + _strict_response_validation=True, + ) as client: + _cache_browser(client) + client.browsers.process.exec("sess-1", command="echo", args=["hi"]) + + assert route.called + + +@respx.mock +def test_browser_request_uses_curl_raw() -> None: + route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( + return_value=httpx.Response(200, content=b"ok") + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + _cache_browser(client) + response = client.browsers.request("sess-1", "GET", "https://example.com", params={"timeout_ms": 5000}) + + assert response.status_code == 200 + assert response.content == b"ok" + request = cast(httpx.Request, cast(Any, route.calls[0]).request) + assert "curl/raw" in str(request.url) + assert request.url.params.get("jwt") == "token-abc" + + +@respx.mock +def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: + route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( + return_value=httpx.Response(200, content=b"ok") + ) + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + _cache_browser(client) + client.browsers.request( + "sess-1", + "GET", + "https://example.com", + params={"url": "https://evil.example", "jwt": "other", "timeout_ms": 1}, + ) + + request = cast(httpx.Request, cast(Any, route.calls[0]).request) + assert str(request.url.params.get("url")) == "https://example.com" + assert str(request.url.params.get("jwt")) == "token-abc" + assert str(request.url.params.get("timeout_ms")) == "1" + + +def test_browser_request_requires_cached_route() -> None: + with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: + _cache_browser(client) + client.browser_route_cache.delete("sess-1") + with pytest.raises(ValueError, match="route cache"): + client.browsers.request("sess-1", "GET", "https://example.com") + + +def test_browser_route_from_browser_requires_base_url_and_jwt() -> None: + assert browser_route_from_browser({**_fake_browser(), "base_url": None}) is None + assert browser_route_from_browser({**_fake_browser(), "cdp_ws_url": None}) is None diff --git a/tests/test_browser_scoped.py b/tests/test_browser_scoped.py deleted file mode 100644 index 2681addc..00000000 --- a/tests/test_browser_scoped.py +++ /dev/null @@ -1,203 +0,0 @@ -from __future__ import annotations - -import os -import json -from typing import Any, cast - -import httpx -import respx -import pytest - -from kernel import Kernel -from kernel.lib.browser_scoped.routing import BrowserRoutingConfig -from kernel.lib.browser_scoped.util import jwt_from_cdp_ws_url - -base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") -api_key = "sk-123" - - -def _fake_browser() -> dict[str, object]: - return { - "session_id": "sess-1", - "base_url": "http://browser-session.test/browser/kernel", - "cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=token-abc", - "webdriver_ws_url": "wss://x", - "created_at": "2020-01-01T00:00:00Z", - "headless": True, - "stealth": False, - "timeout_seconds": 60, - } - - -def test_jwt_from_cdp_ws_url() -> None: - assert jwt_from_cdp_ws_url("wss://h/browser/cdp?jwt=abc%2Fdef&x=1") == "abc/def" - - -@respx.mock -def test_main_client_routes_allowlisted_browser_subresources_directly_to_vm() -> None: - route = respx.post("http://browser-session.test/browser/kernel/process/exec").mock( - return_value=httpx.Response( - 200, - json={ - "exit_code": 0, - "stdout_b64": "", - "stderr_b64": "", - }, - ) - ) - with Kernel( - base_url=base_url, - api_key=api_key, - browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",)), - _strict_response_validation=True, - ) as client: - client.prime_browser_route_cache(_fake_browser()) - out = client.browsers.process.exec("sess-1", command="echo", args=["hi"]) - assert route.called - call = cast(Any, route.calls[0]) - request = cast(httpx.Request, call.request) - assert request.url.params.get("jwt") == "token-abc" - assert request.headers.get("Authorization") is None - assert out.exit_code == 0 - - -@respx.mock -def test_main_client_skips_direct_vm_routing_outside_allowlist() -> None: - route = respx.post(f"{base_url}/browsers/sess-1/process/exec").mock( - return_value=httpx.Response( - 200, - json={ - "exit_code": 0, - "stdout_b64": "", - "stderr_b64": "", - }, - ) - ) - with Kernel( - base_url=base_url, - api_key=api_key, - browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("computer",)), - _strict_response_validation=True, - ) as client: - client.prime_browser_route_cache(_fake_browser()) - client.browsers.process.exec("sess-1", command="echo", args=["hi"]) - assert route.called - - -@respx.mock -def test_browser_process_exec_uses_session_id_with_cache_primed() -> None: - route = respx.post("http://browser-session.test/browser/kernel/process/exec?jwt=token-abc").mock( - return_value=httpx.Response( - 200, - json={ - "exit_code": 0, - "stdout_b64": "", - "stderr_b64": "", - }, - ) - ) - with Kernel( - base_url=base_url, - api_key=api_key, - browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",)), - _strict_response_validation=True, - ) as client: - client.prime_browser_route_cache(_fake_browser()) - out = client.browsers.process.exec("sess-1", command="echo", args=["hi"]) - assert route.called - call = cast(Any, route.calls[0]) - request = cast(httpx.Request, call.request) - sent = request.read().decode() - body = json.loads(sent) - assert body["command"] == "echo" - assert body["args"] == ["hi"] - assert out.exit_code == 0 - - -@respx.mock -def test_browser_request_uses_curl_raw() -> None: - route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( - return_value=httpx.Response(200, content=b"ok") - ) - with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - client.prime_browser_route_cache(_fake_browser()) - r = client.browsers.request("sess-1", "GET", "https://example.com", params={"timeout_ms": 5000}) - assert r.status_code == 200 - assert r.content == b"ok" - assert route.called - call = cast(Any, route.calls[0]) - request = cast(httpx.Request, call.request) - assert "curl/raw" in str(request.url) - assert "jwt=token-abc" in str(request.url) - - -@respx.mock -def test_browser_request_params_cannot_override_target_url_or_jwt() -> None: - route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( - return_value=httpx.Response(200, content=b"ok") - ) - with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - client.prime_browser_route_cache(_fake_browser()) - client.browsers.request( - "sess-1", - "GET", - "https://example.com", - params={"url": "https://evil.example", "jwt": "other", "timeout_ms": 1}, - ) - assert route.called - call = cast(Any, route.calls[0]) - request = cast(httpx.Request, call.request) - req_url = request.url - assert str(req_url.params.get("url")) == "https://example.com" - assert str(req_url.params.get("jwt")) == "token-abc" - assert str(req_url.params.get("timeout_ms")) == "1" - - -def test_browser_request_requires_cached_route() -> None: - with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - client.prime_browser_route_cache(_fake_browser()) - client.browser_route_cache.delete("sess-1") - with pytest.raises(ValueError, match="route cache"): - client.browsers.request("sess-1", "GET", "https://example.com") - - -@respx.mock -def test_browser_stream_params_cannot_override_target_url_or_jwt() -> None: - route = respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( - return_value=httpx.Response(200, content=b"streamed") - ) - with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - client.prime_browser_route_cache(_fake_browser()) - with client.browsers.stream( - "sess-1", - "GET", - "https://example.com", - params={"url": "https://evil.example", "jwt": "other"}, - ) as resp: - assert resp.status_code == 200 - assert resp.read() == b"streamed" - assert route.called - call = cast(Any, route.calls[0]) - request = cast(httpx.Request, call.request) - req_url = request.url - assert str(req_url.params.get("url")) == "https://example.com" - assert str(req_url.params.get("jwt")) == "token-abc" - - -@respx.mock -def test_browser_stream_reads_body() -> None: - respx.get("http://browser-session.test/browser/kernel/curl/raw").mock( - return_value=httpx.Response(200, content=b"streamed") - ) - with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - client.prime_browser_route_cache(_fake_browser()) - with client.browsers.stream("sess-1", "GET", "https://example.com") as resp: - assert resp.status_code == 200 - assert resp.read() == b"streamed" - - -def test_prime_browser_route_cache_requires_base_url() -> None: - bad = {**_fake_browser(), "base_url": None} - with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client: - with pytest.raises(ValueError, match="base_url"): - client.prime_browser_route_cache(bad) From 3ae9dab6b841e6f1191cdde073e36696f97feb39 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 12:57:08 -0400 Subject: [PATCH 13/17] refactor: rename browser routing subresources config Shorten the browser_routing allowlist field to subresources so the direct-to-VM configuration stays concise while keeping the same routing behavior. Made-with: Cursor --- examples/browser_routing.py | 2 +- src/kernel/lib/browser_scoped/routing.py | 4 ++-- tests/test_browser_routing.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/browser_routing.py b/examples/browser_routing.py index e987fc0a..8859427b 100644 --- a/examples/browser_routing.py +++ b/examples/browser_routing.py @@ -4,7 +4,7 @@ def main() -> None: - with Kernel(browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",))) as client: + with Kernel(browser_routing=BrowserRoutingConfig(enabled=True, subresources=("process",))) as client: browser = client.browsers.create(headless=True) try: client.browsers.process.exec(browser.session_id, command="uname", args=["-a"]) diff --git a/src/kernel/lib/browser_scoped/routing.py b/src/kernel/lib/browser_scoped/routing.py index 8f13a4a6..1ad97ce1 100644 --- a/src/kernel/lib/browser_scoped/routing.py +++ b/src/kernel/lib/browser_scoped/routing.py @@ -20,7 +20,7 @@ class BrowserRoute: @dataclass class BrowserRoutingConfig: enabled: bool = False - direct_to_vm_subresources: tuple[str, ...] = field(default_factory=tuple) + subresources: tuple[str, ...] = field(default_factory=tuple) class BrowserRouteCache: @@ -75,7 +75,7 @@ def rewrite_direct_vm_options( return options session_id, subresource, suffix = match - if subresource not in set(config.direct_to_vm_subresources): + if subresource not in set(config.subresources): return options route = cache.get(session_id) diff --git a/tests/test_browser_routing.py b/tests/test_browser_routing.py index 83cfe361..bc3fdb42 100644 --- a/tests/test_browser_routing.py +++ b/tests/test_browser_routing.py @@ -46,7 +46,7 @@ def test_routes_allowlisted_browser_subresources_directly_to_vm() -> None: with Kernel( base_url=base_url, api_key=api_key, - browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",)), + browser_routing=BrowserRoutingConfig(enabled=True, subresources=("process",)), _strict_response_validation=True, ) as client: _cache_browser(client) @@ -67,7 +67,7 @@ def test_skips_direct_vm_routing_outside_allowlist() -> None: with Kernel( base_url=base_url, api_key=api_key, - browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("computer",)), + browser_routing=BrowserRoutingConfig(enabled=True, subresources=("computer",)), _strict_response_validation=True, ) as client: _cache_browser(client) From 622f8448a8a32f00b41d6e4890bfaf0a9374bd3e Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 13:32:27 -0400 Subject: [PATCH 14/17] refactor: clean up python browser routing diff Move the handwritten routing helpers out of the old browser_scoped package, delete the unused browser session clone helper, warm the async browser list cache, and drop the generated type churn from the branch. Made-with: Cursor --- src/kernel/_client.py | 2 +- .../__init__.py | 0 .../raw_http.py | 74 +++++++------ .../routing.py | 4 +- .../util.py | 32 +++--- .../browser_scoped/browser_session_kernel.py | 101 ------------------ src/kernel/resources/browsers/browsers.py | 11 +- src/kernel/types/browser_create_response.py | 2 +- src/kernel/types/browser_list_response.py | 2 +- .../types/browser_pool_acquire_response.py | 2 +- src/kernel/types/browser_retrieve_response.py | 2 +- src/kernel/types/browser_update_response.py | 2 +- .../invocation_list_browsers_response.py | 2 +- tests/test_browser_routing.py | 4 +- 14 files changed, 69 insertions(+), 171 deletions(-) rename src/kernel/lib/{browser_scoped => browser_routing}/__init__.py (100%) rename src/kernel/lib/{browser_scoped => browser_routing}/raw_http.py (68%) rename src/kernel/lib/{browser_scoped => browser_routing}/routing.py (96%) rename src/kernel/lib/{browser_scoped => browser_routing}/util.py (69%) delete mode 100644 src/kernel/lib/browser_scoped/browser_session_kernel.py diff --git a/src/kernel/_client.py b/src/kernel/_client.py index 8563715f..fdfcf843 100644 --- a/src/kernel/_client.py +++ b/src/kernel/_client.py @@ -29,7 +29,7 @@ SyncAPIClient, AsyncAPIClient, ) -from .lib.browser_scoped.routing import ( +from .lib.browser_routing.routing import ( BrowserRouteCache, BrowserRoutingConfig, rewrite_direct_vm_options, diff --git a/src/kernel/lib/browser_scoped/__init__.py b/src/kernel/lib/browser_routing/__init__.py similarity index 100% rename from src/kernel/lib/browser_scoped/__init__.py rename to src/kernel/lib/browser_routing/__init__.py diff --git a/src/kernel/lib/browser_scoped/raw_http.py b/src/kernel/lib/browser_routing/raw_http.py similarity index 68% rename from src/kernel/lib/browser_scoped/raw_http.py rename to src/kernel/lib/browser_routing/raw_http.py index 13aa5c12..377ebc89 100644 --- a/src/kernel/lib/browser_scoped/raw_http.py +++ b/src/kernel/lib/browser_routing/raw_http.py @@ -1,13 +1,13 @@ from __future__ import annotations +from collections.abc import AsyncIterator, Iterable, Iterator from contextlib import asynccontextmanager, contextmanager from typing import IO, Any, Mapping, cast -from collections.abc import AsyncIterator, Iterable, Iterator import httpx from ..._models import FinalRequestOptions -from ..._types import Body, BinaryTypes, NotGiven, Timeout, not_given +from ..._types import BinaryTypes, Body, NotGiven, Timeout, not_given from .routing import BrowserRoute from .util import sanitize_curl_raw_params @@ -26,18 +26,17 @@ def request_via_browser_route( ) -> httpx.Response: if json is not None and content is not None: raise TypeError("Passing both `json` and `content` is not supported") - q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} - q["jwt"] = route.jwt - opts = FinalRequestOptions.construct( + query: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url, "jwt": route.jwt} + options = FinalRequestOptions.construct( method=method.upper(), url=route.base_url.rstrip("/") + "/curl/raw", - params=q, + params=query, headers=headers or {}, content=_normalize_binary_content(content), json_data=json, timeout=timeout, ) - return cast(httpx.Response, parent.request(httpx.Response, opts)) + return cast(httpx.Response, parent.request(httpx.Response, options)) @contextmanager @@ -52,25 +51,25 @@ def stream_via_browser_route( params: Mapping[str, object] | None = None, timeout: float | Timeout | None | NotGiven = not_given, ) -> Iterator[httpx.Response]: - q: dict[str, Any] = sanitize_curl_raw_params(params) - q["jwt"] = route.jwt - q["url"] = url - h = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} + query: dict[str, Any] = sanitize_curl_raw_params(params) + query["jwt"] = route.jwt + query["url"] = url + request_headers = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} if content is None: - h.pop("Content-Type", None) + request_headers.pop("Content-Type", None) if headers: - h.update(headers) - h.pop("Authorization", None) - eff_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout + request_headers.update(headers) + request_headers.pop("Authorization", None) + effective_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout with parent._client.stream( method.upper(), route.base_url.rstrip("/") + "/curl/raw", - params=q, - headers=h, + params=query, + headers=request_headers, content=_normalize_binary_content(content), - timeout=_normalize_timeout(eff_timeout), - ) as resp: - yield resp + timeout=_normalize_timeout(effective_timeout), + ) as response: + yield response async def async_request_via_browser_route( @@ -87,18 +86,17 @@ async def async_request_via_browser_route( ) -> httpx.Response: if json is not None and content is not None: raise TypeError("Passing both `json` and `content` is not supported") - q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url} - q["jwt"] = route.jwt - opts = FinalRequestOptions.construct( + query: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url, "jwt": route.jwt} + options = FinalRequestOptions.construct( method=method.upper(), url=route.base_url.rstrip("/") + "/curl/raw", - params=q, + params=query, headers=headers or {}, content=_normalize_binary_content(content), json_data=json, timeout=timeout, ) - return cast(httpx.Response, await parent.request(httpx.Response, opts)) + return cast(httpx.Response, await parent.request(httpx.Response, options)) @asynccontextmanager @@ -113,25 +111,25 @@ async def async_stream_via_browser_route( params: Mapping[str, object] | None = None, timeout: float | Timeout | None | NotGiven = not_given, ) -> AsyncIterator[httpx.Response]: - q: dict[str, Any] = sanitize_curl_raw_params(params) - q["jwt"] = route.jwt - q["url"] = url - h = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} + query: dict[str, Any] = sanitize_curl_raw_params(params) + query["jwt"] = route.jwt + query["url"] = url + request_headers = {k: v for k, v in parent.default_headers.items() if isinstance(v, str)} if content is None: - h.pop("Content-Type", None) + request_headers.pop("Content-Type", None) if headers: - h.update(headers) - h.pop("Authorization", None) - eff_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout + request_headers.update(headers) + request_headers.pop("Authorization", None) + effective_timeout = parent.timeout if isinstance(timeout, NotGiven) else timeout async with parent._client.stream( method.upper(), route.base_url.rstrip("/") + "/curl/raw", - params=q, - headers=h, + params=query, + headers=request_headers, content=_normalize_binary_content(content), - timeout=_normalize_timeout(eff_timeout), - ) as resp: - yield resp + timeout=_normalize_timeout(effective_timeout), + ) as response: + yield response def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Timeout | None: diff --git a/src/kernel/lib/browser_scoped/routing.py b/src/kernel/lib/browser_routing/routing.py similarity index 96% rename from src/kernel/lib/browser_scoped/routing.py rename to src/kernel/lib/browser_routing/routing.py index 1ad97ce1..e5296654 100644 --- a/src/kernel/lib/browser_scoped/routing.py +++ b/src/kernel/lib/browser_routing/routing.py @@ -7,7 +7,7 @@ from ..._compat import model_copy from ..._models import FinalRequestOptions -from .util import base_url_from_browser_like, jwt_from_cdp_ws_url, cdp_ws_url_from_browser_like, session_id_from_browser_like +from .util import base_url_from_browser_like, cdp_ws_url_from_browser_like, jwt_from_cdp_ws_url, session_id_from_browser_like @dataclass @@ -118,5 +118,3 @@ def match_direct_vm_path(path: str) -> tuple[str, str, str] | None: suffix = "/" + "/".join(parts[index + 3 :]) return session_id, subresource, suffix return None - - diff --git a/src/kernel/lib/browser_scoped/util.py b/src/kernel/lib/browser_routing/util.py similarity index 69% rename from src/kernel/lib/browser_scoped/util.py rename to src/kernel/lib/browser_routing/util.py index 876fa0e6..ecfb7331 100644 --- a/src/kernel/lib/browser_scoped/util.py +++ b/src/kernel/lib/browser_routing/util.py @@ -28,33 +28,31 @@ def session_id_from_browser_like(browser: Any) -> str: return sid if isinstance(browser, Mapping): mapping = cast(Mapping[str, object], browser) - m = mapping.get("session_id") - if isinstance(m, str) and m: - return m + value = mapping.get("session_id") + if isinstance(value, str) and value: + return value raise TypeError("browser object must have a non-empty session_id") def base_url_from_browser_like(browser: Any) -> str | None: - bu = getattr(browser, "base_url", None) - if isinstance(bu, str) and bu.strip(): - return bu.strip().rstrip("/") + "/" + base_url = getattr(browser, "base_url", None) + if isinstance(base_url, str) and base_url.strip(): + return base_url.strip().rstrip("/") + "/" if isinstance(browser, Mapping): mapping = cast(Mapping[str, object], browser) - raw = mapping.get("base_url") - if isinstance(raw, str) and raw.strip(): - return raw.strip().rstrip("/") + "/" + value = mapping.get("base_url") + if isinstance(value, str) and value.strip(): + return value.strip().rstrip("/") + "/" return None def cdp_ws_url_from_browser_like(browser: Any) -> str: - u = getattr(browser, "cdp_ws_url", None) - if isinstance(u, str) and u: - return u + cdp_ws_url = getattr(browser, "cdp_ws_url", None) + if isinstance(cdp_ws_url, str) and cdp_ws_url: + return cdp_ws_url if isinstance(browser, Mapping): mapping = cast(Mapping[str, object], browser) - m = mapping.get("cdp_ws_url") - if isinstance(m, str) and m: - return m + value = mapping.get("cdp_ws_url") + if isinstance(value, str) and value: + return value raise TypeError("browser object must have a non-empty cdp_ws_url") - - diff --git a/src/kernel/lib/browser_scoped/browser_session_kernel.py b/src/kernel/lib/browser_scoped/browser_session_kernel.py deleted file mode 100644 index 55e25d76..00000000 --- a/src/kernel/lib/browser_scoped/browser_session_kernel.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Internal Kernel clones for browser session HTTP (base_url + /browser/kernel paths).""" - -from __future__ import annotations - -from typing import Any, Mapping, cast -from typing_extensions import override - -from ..._client import Kernel, AsyncKernel -from ..._compat import model_copy -from ..._models import FinalRequestOptions - - -class _BrowserSessionKernel(Kernel): - """Kernel clone whose HTTP base is the browser session; strips /browsers/{id} from paths.""" - - _scoped_session_id: str - - def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None: - self._scoped_session_id = browser_session_id - super().__init__(**kwargs) - - @override - def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: - options = super()._prepare_options(options) - url = options.url - prefix = f"/browsers/{self._scoped_session_id}/" - if not url.startswith(prefix): - return options - suffix = url[len(prefix) :].lstrip("/") - new_url = f"/{suffix}" if suffix else "/" - out = model_copy(options) - out.url = new_url - return out - - -class _BrowserSessionAsyncKernel(AsyncKernel): - _scoped_session_id: str - - def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None: - self._scoped_session_id = browser_session_id - super().__init__(**kwargs) - - @override - async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: - options = await super()._prepare_options(options) - url = options.url - prefix = f"/browsers/{self._scoped_session_id}/" - if not url.startswith(prefix): - return options - suffix = url[len(prefix) :].lstrip("/") - new_url = f"/{suffix}" if suffix else "/" - out = model_copy(options) - out.url = new_url - return out - - -def build_browser_session_kernel( - parent: Kernel, *, session_id: str, session_base_url: str, jwt: str -) -> _BrowserSessionKernel: - """Build a sync client sharing the parent's httpx transport; requests use session_base_url.""" - base_q_raw = getattr(parent, "_custom_query", None) - if isinstance(base_q_raw, Mapping): - base_q = {str(k): v for k, v in cast(Mapping[str, object], base_q_raw).items()} - else: - base_q = {} - dq = dict(base_q) - dq["jwt"] = jwt - return _BrowserSessionKernel( - browser_session_id=session_id, - api_key=parent.api_key, - base_url=session_base_url, - timeout=parent.timeout, - max_retries=parent.max_retries, - http_client=parent._client, - default_headers=dict(parent._custom_headers), - default_query=dq, - _strict_response_validation=getattr(parent, "_strict_response_validation", False), - ) - - -def build_async_browser_session_kernel( - parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str -) -> _BrowserSessionAsyncKernel: - base_q_raw = getattr(parent, "_custom_query", None) - if isinstance(base_q_raw, Mapping): - base_q = {str(k): v for k, v in cast(Mapping[str, object], base_q_raw).items()} - else: - base_q = {} - dq = dict(base_q) - dq["jwt"] = jwt - return _BrowserSessionAsyncKernel( - browser_session_id=session_id, - api_key=parent.api_key, - base_url=session_base_url, - timeout=parent.timeout, - max_retries=parent.max_retries, - http_client=parent._client, - default_headers=dict(parent._custom_headers), - default_query=dq, - _strict_response_validation=getattr(parent, "_strict_response_validation", False), - ) diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index 70bc6635..df9f94ef 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -88,13 +88,13 @@ from ...types.shared_params.browser_profile import BrowserProfile from ...types.shared_params.browser_viewport import BrowserViewport from ...types.shared_params.browser_extension import BrowserExtension -from ...lib.browser_scoped.raw_http import ( +from ...lib.browser_routing.raw_http import ( async_request_via_browser_route, async_stream_via_browser_route, request_via_browser_route, stream_via_browser_route, ) -from ...lib.browser_scoped.routing import browser_route_from_browser +from ...lib.browser_routing.routing import browser_route_from_browser __all__ = ["BrowsersResource", "AsyncBrowsersResource"] @@ -978,7 +978,7 @@ def list( timeout: Override the client-level default timeout for this request, in seconds """ - return self._get_api_list( + page = self._get_api_list( "/browsers", page=AsyncOffsetPagination[BrowserListResponse], options=make_request_options( @@ -999,6 +999,11 @@ def list( ), model=BrowserListResponse, ) + for item in page.items: + route = browser_route_from_browser(item) + if route is not None: + self._client.browser_route_cache.set(route) + return page @typing_extensions.deprecated("deprecated") async def delete( diff --git a/src/kernel/types/browser_create_response.py b/src/kernel/types/browser_create_response.py index a793eb2f..9356bb05 100644 --- a/src/kernel/types/browser_create_response.py +++ b/src/kernel/types/browser_create_response.py @@ -36,7 +36,7 @@ class BrowserCreateResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """HTTP base URL for this browser session (browser VM / session proxy).""" + """Metro-API HTTP base URL for this browser session.""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_list_response.py b/src/kernel/types/browser_list_response.py index 43e60cd1..f3a88f29 100644 --- a/src/kernel/types/browser_list_response.py +++ b/src/kernel/types/browser_list_response.py @@ -36,7 +36,7 @@ class BrowserListResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """HTTP base URL for this browser session (browser VM / session proxy).""" + """Metro-API HTTP base URL for this browser session.""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_pool_acquire_response.py b/src/kernel/types/browser_pool_acquire_response.py index ea37ba65..064c405d 100644 --- a/src/kernel/types/browser_pool_acquire_response.py +++ b/src/kernel/types/browser_pool_acquire_response.py @@ -36,7 +36,7 @@ class BrowserPoolAcquireResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """HTTP base URL for this browser session (browser VM / session proxy).""" + """Metro-API HTTP base URL for this browser session.""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_retrieve_response.py b/src/kernel/types/browser_retrieve_response.py index c56d159a..5b5a8913 100644 --- a/src/kernel/types/browser_retrieve_response.py +++ b/src/kernel/types/browser_retrieve_response.py @@ -36,7 +36,7 @@ class BrowserRetrieveResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """HTTP base URL for this browser session (browser VM / session proxy).""" + """Metro-API HTTP base URL for this browser session.""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/browser_update_response.py b/src/kernel/types/browser_update_response.py index 325f8f1f..188895ad 100644 --- a/src/kernel/types/browser_update_response.py +++ b/src/kernel/types/browser_update_response.py @@ -36,7 +36,7 @@ class BrowserUpdateResponse(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """HTTP base URL for this browser session (browser VM / session proxy).""" + """Metro-API HTTP base URL for this browser session.""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/src/kernel/types/invocation_list_browsers_response.py b/src/kernel/types/invocation_list_browsers_response.py index e99b5087..23eda779 100644 --- a/src/kernel/types/invocation_list_browsers_response.py +++ b/src/kernel/types/invocation_list_browsers_response.py @@ -36,7 +36,7 @@ class Browser(BaseModel): """Websocket URL for WebDriver BiDi connections to the browser session""" base_url: Optional[str] = None - """HTTP base URL for this browser session (browser VM / session proxy).""" + """Metro-API HTTP base URL for this browser session.""" browser_live_view_url: Optional[str] = None """Remote URL for live viewing the browser session. diff --git a/tests/test_browser_routing.py b/tests/test_browser_routing.py index bc3fdb42..fc584f3b 100644 --- a/tests/test_browser_routing.py +++ b/tests/test_browser_routing.py @@ -8,8 +8,8 @@ import respx from kernel import Kernel -from kernel.lib.browser_scoped.routing import BrowserRoutingConfig, browser_route_from_browser -from kernel.lib.browser_scoped.util import jwt_from_cdp_ws_url +from kernel.lib.browser_routing.routing import BrowserRoutingConfig, browser_route_from_browser +from kernel.lib.browser_routing.util import jwt_from_cdp_ws_url base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "sk-123" From 694907ab3419477e7058b85a7365ac4cce941105 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 20:05:17 -0400 Subject: [PATCH 15/17] fix: finish python browser routing cleanup Preserve browser routing settings across copy(), skip cache warming for raw response wrappers, and clean up the handwritten routing files so lint can pass on the current branch. Made-with: Cursor --- examples/browser_routing.py | 17 +++++++++++------ src/kernel/__init__.py | 2 +- src/kernel/_client.py | 14 +++++++++----- src/kernel/lib/browser_routing/__init__.py | 2 ++ src/kernel/lib/browser_routing/raw_http.py | 10 +++++----- src/kernel/lib/browser_routing/routing.py | 15 ++++++++++++--- src/kernel/resources/browsers/browsers.py | 18 +++++++++--------- tests/test_browser_routing.py | 4 ++-- 8 files changed, 51 insertions(+), 31 deletions(-) diff --git a/examples/browser_routing.py b/examples/browser_routing.py index 8859427b..0879fdad 100644 --- a/examples/browser_routing.py +++ b/examples/browser_routing.py @@ -1,21 +1,26 @@ """Example: direct-to-VM browser routing for process exec and raw HTTP.""" -from kernel import BrowserRoutingConfig, Kernel +from typing import Any, cast + +import httpx + +from kernel import Kernel, BrowserRoutingConfig def main() -> None: with Kernel(browser_routing=BrowserRoutingConfig(enabled=True, subresources=("process",))) as client: - browser = client.browsers.create(headless=True) + browsers = cast(Any, client.browsers) + browser = browsers.create(headless=True) try: - client.browsers.process.exec(browser.session_id, command="uname", args=["-a"]) + browsers.process.exec(browser.session_id, command="uname", args=["-a"]) - response = client.browsers.request(browser.session_id, "GET", "https://example.com") + response = cast(httpx.Response, browsers.request(browser.session_id, "GET", "https://example.com")) print("status", response.status_code) - with client.browsers.stream(browser.session_id, "GET", "https://example.com") as streamed: + with cast(Any, browsers.stream(browser.session_id, "GET", "https://example.com")) as streamed: print("streamed-bytes", len(streamed.read())) finally: - client.browsers.delete_by_id(browser.session_id) + browsers.delete_by_id(browser.session_id) if __name__ == "__main__": diff --git a/src/kernel/__init__.py b/src/kernel/__init__.py index 7ceb61cd..e9838867 100644 --- a/src/kernel/__init__.py +++ b/src/kernel/__init__.py @@ -7,7 +7,6 @@ from ._utils import file_from_path from ._client import ( ENVIRONMENTS, - BrowserRoutingConfig, Client, Kernel, Stream, @@ -17,6 +16,7 @@ AsyncKernel, AsyncStream, RequestOptions, + BrowserRoutingConfig, ) from ._models import BaseModel from ._version import __title__, __version__ diff --git a/src/kernel/_client.py b/src/kernel/_client.py index fdfcf843..e5929769 100644 --- a/src/kernel/_client.py +++ b/src/kernel/_client.py @@ -32,8 +32,8 @@ from .lib.browser_routing.routing import ( BrowserRouteCache, BrowserRoutingConfig, - rewrite_direct_vm_options, strip_direct_vm_auth, + rewrite_direct_vm_options, ) if TYPE_CHECKING: @@ -301,6 +301,8 @@ def copy( set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, set_default_query: Mapping[str, object] | None = None, + browser_routing: BrowserRoutingConfig | None = None, + _browser_route_cache: BrowserRouteCache | None = None, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ @@ -334,8 +336,8 @@ def copy( max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, - browser_routing=self._browser_routing, - _browser_route_cache=self.browser_route_cache, + browser_routing=browser_routing if browser_routing is not None else self._browser_routing, + _browser_route_cache=_browser_route_cache or self.browser_route_cache, **_extra_kwargs, ) @@ -595,6 +597,8 @@ def copy( set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, set_default_query: Mapping[str, object] | None = None, + browser_routing: BrowserRoutingConfig | None = None, + _browser_route_cache: BrowserRouteCache | None = None, _extra_kwargs: Mapping[str, Any] = {}, ) -> Self: """ @@ -628,8 +632,8 @@ def copy( max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, - browser_routing=self._browser_routing, - _browser_route_cache=self.browser_route_cache, + browser_routing=browser_routing if browser_routing is not None else self._browser_routing, + _browser_route_cache=_browser_route_cache or self.browser_route_cache, **_extra_kwargs, ) diff --git a/src/kernel/lib/browser_routing/__init__.py b/src/kernel/lib/browser_routing/__init__.py index c9c2ef67..bdec2fc8 100644 --- a/src/kernel/lib/browser_routing/__init__.py +++ b/src/kernel/lib/browser_routing/__init__.py @@ -1 +1,3 @@ +from __future__ import annotations + __all__: list[str] = [] diff --git a/src/kernel/lib/browser_routing/raw_http.py b/src/kernel/lib/browser_routing/raw_http.py index 377ebc89..e7bfb7c2 100644 --- a/src/kernel/lib/browser_routing/raw_http.py +++ b/src/kernel/lib/browser_routing/raw_http.py @@ -1,15 +1,15 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Iterable, Iterator -from contextlib import asynccontextmanager, contextmanager from typing import IO, Any, Mapping, cast +from contextlib import contextmanager, asynccontextmanager +from collections.abc import Iterable, Iterator, AsyncIterator import httpx -from ..._models import FinalRequestOptions -from ..._types import BinaryTypes, Body, NotGiven, Timeout, not_given -from .routing import BrowserRoute from .util import sanitize_curl_raw_params +from .routing import BrowserRoute +from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given +from ..._models import FinalRequestOptions def request_via_browser_route( diff --git a/src/kernel/lib/browser_routing/routing.py b/src/kernel/lib/browser_routing/routing.py index e5296654..31e04ada 100644 --- a/src/kernel/lib/browser_routing/routing.py +++ b/src/kernel/lib/browser_routing/routing.py @@ -1,13 +1,18 @@ from __future__ import annotations -from dataclasses import dataclass, field from typing import Any, Mapping, cast +from dataclasses import field, dataclass import httpx +from .util import ( + jwt_from_cdp_ws_url, + base_url_from_browser_like, + cdp_ws_url_from_browser_like, + session_id_from_browser_like, +) from ..._compat import model_copy from ..._models import FinalRequestOptions -from .util import base_url_from_browser_like, cdp_ws_url_from_browser_like, jwt_from_cdp_ws_url, session_id_from_browser_like @dataclass @@ -45,7 +50,11 @@ def values(self) -> list[BrowserRoute]: def browser_route_from_browser(browser: Any) -> BrowserRoute | None: - session_id = session_id_from_browser_like(browser) + try: + session_id = session_id_from_browser_like(browser) + except TypeError: + return None + base_url = base_url_from_browser_like(browser) if not base_url: return None diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index df9f94ef..baa889aa 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -2,9 +2,9 @@ from __future__ import annotations -from contextlib import asynccontextmanager, contextmanager import typing_extensions -from typing import AsyncIterator, Dict, Iterator, Mapping, Iterable, Optional, cast +from typing import Dict, Mapping, Iterable, Iterator, Optional, AsyncIterator, cast +from contextlib import contextmanager, asynccontextmanager from typing_extensions import Literal import httpx @@ -79,8 +79,15 @@ ) from ...pagination import SyncOffsetPagination, AsyncOffsetPagination from ..._base_client import AsyncPaginator, make_request_options +from ...lib.browser_routing.routing import browser_route_from_browser from ...types.browser_curl_response import BrowserCurlResponse from ...types.browser_list_response import BrowserListResponse +from ...lib.browser_routing.raw_http import ( + stream_via_browser_route, + request_via_browser_route, + async_stream_via_browser_route, + async_request_via_browser_route, +) from ...types.browser_create_response import BrowserCreateResponse from ...types.browser_update_response import BrowserUpdateResponse from ...types.browser_persistence_param import BrowserPersistenceParam @@ -88,13 +95,6 @@ from ...types.shared_params.browser_profile import BrowserProfile from ...types.shared_params.browser_viewport import BrowserViewport from ...types.shared_params.browser_extension import BrowserExtension -from ...lib.browser_routing.raw_http import ( - async_request_via_browser_route, - async_stream_via_browser_route, - request_via_browser_route, - stream_via_browser_route, -) -from ...lib.browser_routing.routing import browser_route_from_browser __all__ = ["BrowsersResource", "AsyncBrowsersResource"] diff --git a/tests/test_browser_routing.py b/tests/test_browser_routing.py index fc584f3b..668f7541 100644 --- a/tests/test_browser_routing.py +++ b/tests/test_browser_routing.py @@ -4,12 +4,12 @@ from typing import Any, cast import httpx -import pytest import respx +import pytest from kernel import Kernel -from kernel.lib.browser_routing.routing import BrowserRoutingConfig, browser_route_from_browser from kernel.lib.browser_routing.util import jwt_from_cdp_ws_url +from kernel.lib.browser_routing.routing import BrowserRoutingConfig, browser_route_from_browser base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "sk-123" From 9690923666cfe07de76267eee050d7743a8bad6f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 20:08:55 -0400 Subject: [PATCH 16/17] fix: address python browser routing ci follow-ups Make the browser routing helpers type-check cleanly in CI, keep copy() signatures aligned with __init__, and avoid cache-warming errors on raw response wrappers. Made-with: Cursor --- examples/browser_routing.py | 2 +- src/kernel/lib/browser_routing/raw_http.py | 20 +++++++++++--------- src/kernel/lib/browser_routing/routing.py | 5 ++--- src/kernel/resources/browsers/browsers.py | 3 ++- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/examples/browser_routing.py b/examples/browser_routing.py index 0879fdad..26110fc7 100644 --- a/examples/browser_routing.py +++ b/examples/browser_routing.py @@ -17,7 +17,7 @@ def main() -> None: response = cast(httpx.Response, browsers.request(browser.session_id, "GET", "https://example.com")) print("status", response.status_code) - with cast(Any, browsers.stream(browser.session_id, "GET", "https://example.com")) as streamed: + with browsers.stream(browser.session_id, "GET", "https://example.com") as streamed: print("streamed-bytes", len(streamed.read())) finally: browsers.delete_by_id(browser.session_id) diff --git a/src/kernel/lib/browser_routing/raw_http.py b/src/kernel/lib/browser_routing/raw_http.py index e7bfb7c2..ad216764 100644 --- a/src/kernel/lib/browser_routing/raw_http.py +++ b/src/kernel/lib/browser_routing/raw_http.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import IO, Any, Mapping, cast +from typing import IO, Any, Union, Mapping, cast from contextlib import contextmanager, asynccontextmanager from collections.abc import Iterable, Iterator, AsyncIterator @@ -8,9 +8,11 @@ from .util import sanitize_curl_raw_params from .routing import BrowserRoute -from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given +from ..._types import Body, Timeout, NotGiven, not_given from ..._models import FinalRequestOptions +BrowserRawContent = Union[bytes, bytearray, memoryview, str, IO[bytes], Iterable[bytes]] + def request_via_browser_route( parent: Any, @@ -18,7 +20,7 @@ def request_via_browser_route( method: str, url: str, *, - content: BinaryTypes | None = None, + content: BrowserRawContent | None = None, json: Body | None = None, headers: Mapping[str, str] | None = None, params: Mapping[str, object] | None = None, @@ -34,7 +36,7 @@ def request_via_browser_route( headers=headers or {}, content=_normalize_binary_content(content), json_data=json, - timeout=timeout, + timeout=_normalize_timeout(timeout), ) return cast(httpx.Response, parent.request(httpx.Response, options)) @@ -46,7 +48,7 @@ def stream_via_browser_route( method: str, url: str, *, - content: BinaryTypes | None = None, + content: BrowserRawContent | None = None, headers: Mapping[str, str] | None = None, params: Mapping[str, object] | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -78,7 +80,7 @@ async def async_request_via_browser_route( method: str, url: str, *, - content: BinaryTypes | None = None, + content: BrowserRawContent | None = None, json: Body | None = None, headers: Mapping[str, str] | None = None, params: Mapping[str, object] | None = None, @@ -94,7 +96,7 @@ async def async_request_via_browser_route( headers=headers or {}, content=_normalize_binary_content(content), json_data=json, - timeout=timeout, + timeout=_normalize_timeout(timeout), ) return cast(httpx.Response, await parent.request(httpx.Response, options)) @@ -106,7 +108,7 @@ async def async_stream_via_browser_route( method: str, url: str, *, - content: BinaryTypes | None = None, + content: BrowserRawContent | None = None, headers: Mapping[str, str] | None = None, params: Mapping[str, object] | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -136,7 +138,7 @@ def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Ti return None if isinstance(timeout, NotGiven) else timeout -def _normalize_binary_content(content: BinaryTypes | None) -> bytes | IO[bytes] | Iterable[bytes] | None: +def _normalize_binary_content(content: BrowserRawContent | None) -> bytes | str | IO[bytes] | Iterable[bytes] | None: if content is None: return None if isinstance(content, bytearray): diff --git a/src/kernel/lib/browser_routing/routing.py b/src/kernel/lib/browser_routing/routing.py index 31e04ada..59c08157 100644 --- a/src/kernel/lib/browser_routing/routing.py +++ b/src/kernel/lib/browser_routing/routing.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Mapping, cast +from typing import Any from dataclasses import field, dataclass import httpx @@ -95,8 +95,7 @@ def rewrite_direct_vm_options( rewritten.url = f"{route.base_url.rstrip('/')}/{subresource}{suffix}" params: dict[str, object] = {} - if isinstance(options.params, Mapping): - params.update(cast(Mapping[str, object], options.params)) + params.update(options.params) params["jwt"] = route.jwt rewritten.params = params or options.params return rewritten diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py index baa889aa..e8524579 100644 --- a/src/kernel/resources/browsers/browsers.py +++ b/src/kernel/resources/browsers/browsers.py @@ -999,7 +999,8 @@ def list( ), model=BrowserListResponse, ) - for item in page.items: + typed_page = cast(AsyncOffsetPagination[BrowserListResponse], page) + for item in typed_page.items: route = browser_route_from_browser(item) if route is not None: self._client.browser_route_cache.set(route) From 3ce80e767d373b638ba1c2959bf18bf999629db0 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 20:10:50 -0400 Subject: [PATCH 17/17] fix: normalize python browser request string bodies Encode string request bodies before building raw /curl/raw request options so the browser routing helpers satisfy CI type checks while preserving the public request API. Made-with: Cursor --- src/kernel/lib/browser_routing/raw_http.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/kernel/lib/browser_routing/raw_http.py b/src/kernel/lib/browser_routing/raw_http.py index ad216764..5e644e39 100644 --- a/src/kernel/lib/browser_routing/raw_http.py +++ b/src/kernel/lib/browser_routing/raw_http.py @@ -138,9 +138,11 @@ def _normalize_timeout(timeout: float | Timeout | None | NotGiven) -> float | Ti return None if isinstance(timeout, NotGiven) else timeout -def _normalize_binary_content(content: BrowserRawContent | None) -> bytes | str | IO[bytes] | Iterable[bytes] | None: +def _normalize_binary_content(content: BrowserRawContent | None) -> bytes | IO[bytes] | Iterable[bytes] | None: if content is None: return None + if isinstance(content, str): + return content.encode() if isinstance(content, bytearray): return bytes(content) if isinstance(content, memoryview):