Skip to content

Commit cfa07c9

Browse files
committed
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
1 parent 888659c commit cfa07c9

11 files changed

+133
-81
lines changed

examples/browser_scoped.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Example: browser-scoped client for metro-backed process and raw HTTP."""
1+
"""Example: browser-scoped client for browser VM process exec and raw HTTP."""
22

33
from kernel import Kernel
44

src/kernel/lib/browser_scoped/metro_client.py renamed to src/kernel/lib/browser_scoped/browser_session_kernel.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Internal HTTP clients that speak to metro-api /browser/kernel paths."""
1+
"""Internal Kernel clones for browser session HTTP (base_url + /browser/kernel paths)."""
22

33
from __future__ import annotations
44

@@ -9,8 +9,8 @@
99
from ..._models import FinalRequestOptions
1010

1111

12-
class _BrowserMetroKernel(Kernel):
13-
"""Kernel client clone whose requests hit metro base_url with /browsers/{id} stripped."""
12+
class _BrowserSessionKernel(Kernel):
13+
"""Kernel clone whose HTTP base is the browser session; strips /browsers/{id} from paths."""
1414

1515
_scoped_session_id: str
1616

@@ -33,7 +33,7 @@ def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
3333
return cast(FinalRequestOptions, out)
3434

3535

36-
class _BrowserMetroAsyncKernel(AsyncKernel):
36+
class _BrowserSessionAsyncKernel(AsyncKernel):
3737
_scoped_session_id: str
3838

3939
def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None:
@@ -55,15 +55,17 @@ async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOp
5555
return cast(FinalRequestOptions, out)
5656

5757

58-
def metro_kernel_from_browser(parent: Kernel, *, session_id: str, metro_base_url: str, jwt: str) -> _BrowserMetroKernel:
59-
"""Build a sync metro-scoped client sharing the parent's httpx transport."""
58+
def build_browser_session_kernel(
59+
parent: Kernel, *, session_id: str, session_base_url: str, jwt: str
60+
) -> _BrowserSessionKernel:
61+
"""Build a sync client sharing the parent's httpx transport; requests use session_base_url."""
6062
base_q = getattr(parent, "_custom_query", None) or {}
6163
dq = {str(k): v for k, v in dict(base_q).items()}
6264
dq["jwt"] = jwt
63-
return _BrowserMetroKernel(
65+
return _BrowserSessionKernel(
6466
browser_session_id=session_id,
6567
api_key=parent.api_key,
66-
base_url=metro_base_url,
68+
base_url=session_base_url,
6769
timeout=parent.timeout,
6870
max_retries=parent.max_retries,
6971
http_client=parent._client,
@@ -73,16 +75,16 @@ def metro_kernel_from_browser(parent: Kernel, *, session_id: str, metro_base_url
7375
)
7476

7577

76-
def metro_async_kernel_from_browser(
77-
parent: AsyncKernel, *, session_id: str, metro_base_url: str, jwt: str
78-
) -> _BrowserMetroAsyncKernel:
78+
def build_async_browser_session_kernel(
79+
parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str
80+
) -> _BrowserSessionAsyncKernel:
7981
base_q = getattr(parent, "_custom_query", None) or {}
8082
dq = {str(k): v for k, v in dict(base_q).items()}
8183
dq["jwt"] = jwt
82-
return _BrowserMetroAsyncKernel(
84+
return _BrowserSessionAsyncKernel(
8385
browser_session_id=session_id,
8486
api_key=parent.api_key,
85-
base_url=metro_base_url,
87+
base_url=session_base_url,
8688
timeout=parent.timeout,
8789
max_retries=parent.max_retries,
8890
http_client=parent._client,

src/kernel/lib/browser_scoped/client.py

Lines changed: 50 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Browser-scoped view over a session: metro-routed subresources and raw HTTP via /curl/raw."""
1+
"""Browser-scoped view over a session: VM subresources and raw HTTP via internal /curl/raw."""
22

33
from __future__ import annotations
44

@@ -11,13 +11,14 @@
1111

1212
from .util import (
1313
jwt_from_cdp_ws_url,
14+
sanitize_curl_raw_params,
1415
base_url_from_browser_like,
1516
cdp_ws_url_from_browser_like,
1617
session_id_from_browser_like,
1718
)
1819
from ..._types import Body, Timeout, NotGiven, BinaryTypes, not_given
1920
from ..._models import FinalRequestOptions
20-
from .metro_client import metro_kernel_from_browser, metro_async_kernel_from_browser
21+
from .browser_session_kernel import build_browser_session_kernel, build_async_browser_session_kernel
2122

2223
if TYPE_CHECKING:
2324
from ..._client import Kernel, AsyncKernel
@@ -60,12 +61,14 @@ def bound(*args: Any, **kwargs: Any) -> Any:
6061
class BrowserScopedClient:
6162
"""Session-scoped API: subresources without repeating session id; HTTP via browser /curl/raw."""
6263

63-
def __init__(self, parent: Kernel, *, session_id: str, metro_base_url: str, jwt: str) -> None:
64+
def __init__(self, parent: Kernel, *, session_id: str, session_base_url: str, jwt: str) -> None:
6465
self._parent = parent
6566
self.session_id = session_id
66-
self._metro_base_url = metro_base_url
67+
self._session_base_url = session_base_url
6768
self._jwt = jwt
68-
self._metro = metro_kernel_from_browser(parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt)
69+
self._http = build_browser_session_kernel(
70+
parent, session_id=session_id, session_base_url=session_base_url, jwt=jwt
71+
)
6972

7073
@property
7174
def parent(self) -> Kernel:
@@ -74,43 +77,43 @@ def parent(self) -> Kernel:
7477

7578
@property
7679
def base_url(self) -> str:
77-
return self._metro_base_url
80+
return self._session_base_url
7881

7982
@property
8083
def process(self) -> ProcessResource:
8184
from ...resources.browsers.process import ProcessResource
8285

83-
return cast(ProcessResource, _BoundBrowserSubresource(ProcessResource(self._metro), self.session_id))
86+
return cast(ProcessResource, _BoundBrowserSubresource(ProcessResource(self._http), self.session_id))
8487

8588
@property
8689
def computer(self) -> ComputerResource:
8790
from ...resources.browsers.computer import ComputerResource
8891

89-
return cast(ComputerResource, _BoundBrowserSubresource(ComputerResource(self._metro), self.session_id))
92+
return cast(ComputerResource, _BoundBrowserSubresource(ComputerResource(self._http), self.session_id))
9093

9194
@property
9295
def fs(self) -> FsResource:
9396
from ...resources.browsers.fs.fs import FsResource
9497

95-
return cast(FsResource, _BoundBrowserSubresource(FsResource(self._metro), self.session_id))
98+
return cast(FsResource, _BoundBrowserSubresource(FsResource(self._http), self.session_id))
9699

97100
@property
98101
def logs(self) -> LogsResource:
99102
from ...resources.browsers.logs import LogsResource
100103

101-
return cast(LogsResource, _BoundBrowserSubresource(LogsResource(self._metro), self.session_id))
104+
return cast(LogsResource, _BoundBrowserSubresource(LogsResource(self._http), self.session_id))
102105

103106
@property
104107
def playwright(self) -> PlaywrightResource:
105108
from ...resources.browsers.playwright import PlaywrightResource
106109

107-
return cast(PlaywrightResource, _BoundBrowserSubresource(PlaywrightResource(self._metro), self.session_id))
110+
return cast(PlaywrightResource, _BoundBrowserSubresource(PlaywrightResource(self._http), self.session_id))
108111

109112
@property
110113
def replays(self) -> ReplaysResource:
111114
from ...resources.browsers.replays import ReplaysResource
112115

113-
return cast(ReplaysResource, _BoundBrowserSubresource(ReplaysResource(self._metro), self.session_id))
116+
return cast(ReplaysResource, _BoundBrowserSubresource(ReplaysResource(self._http), self.session_id))
114117

115118
def request(
116119
self,
@@ -125,9 +128,7 @@ def request(
125128
) -> httpx.Response:
126129
if json is not None and content is not None:
127130
raise TypeError("Passing both `json` and `content` is not supported")
128-
q: dict[str, object] = {"url": url}
129-
if params:
130-
q.update(dict(params))
131+
q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url}
131132
opts = FinalRequestOptions.construct(
132133
method=method.upper(),
133134
url="/curl/raw",
@@ -137,7 +138,7 @@ def request(
137138
json_data=json,
138139
timeout=timeout,
139140
)
140-
return self._metro.request(httpx.Response, opts)
141+
return self._http.request(httpx.Response, opts)
141142

142143
@contextmanager
143144
def stream(
@@ -150,19 +151,18 @@ def stream(
150151
params: Mapping[str, object] | None = None,
151152
timeout: float | Timeout | None | NotGiven = not_given,
152153
) -> Iterator[httpx.Response]:
153-
q: dict[str, Any] = dict(self._metro.default_query)
154+
q: dict[str, Any] = dict(self._http.default_query)
155+
q.update(sanitize_curl_raw_params(params))
154156
q["url"] = url
155-
if params:
156-
q.update(dict(params))
157-
h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)}
157+
h = {k: v for k, v in self._http.default_headers.items() if isinstance(v, str)}
158158
if content is None:
159159
h.pop("Content-Type", None)
160160
if headers:
161161
h.update(headers)
162-
eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout
163-
cm = self._metro._client.stream(
162+
eff_timeout = self._http.timeout if isinstance(timeout, NotGiven) else timeout
163+
cm = self._http._client.stream(
164164
method.upper(),
165-
self._metro._prepare_url("/curl/raw"),
165+
self._http._prepare_url("/curl/raw"),
166166
params=q,
167167
headers=h,
168168
content=content,
@@ -173,13 +173,13 @@ def stream(
173173

174174

175175
class AsyncBrowserScopedClient:
176-
def __init__(self, parent: AsyncKernel, *, session_id: str, metro_base_url: str, jwt: str) -> None:
176+
def __init__(self, parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str) -> None:
177177
self._parent = parent
178178
self.session_id = session_id
179-
self._metro_base_url = metro_base_url
179+
self._session_base_url = session_base_url
180180
self._jwt = jwt
181-
self._metro = metro_async_kernel_from_browser(
182-
parent, session_id=session_id, metro_base_url=metro_base_url, jwt=jwt
181+
self._http = build_async_browser_session_kernel(
182+
parent, session_id=session_id, session_base_url=session_base_url, jwt=jwt
183183
)
184184

185185
@property
@@ -188,47 +188,47 @@ def parent(self) -> AsyncKernel:
188188

189189
@property
190190
def base_url(self) -> str:
191-
return self._metro_base_url
191+
return self._session_base_url
192192

193193
@property
194194
def process(self) -> AsyncProcessResource:
195195
from ...resources.browsers.process import AsyncProcessResource
196196

197-
return cast(AsyncProcessResource, _BoundBrowserSubresource(AsyncProcessResource(self._metro), self.session_id))
197+
return cast(AsyncProcessResource, _BoundBrowserSubresource(AsyncProcessResource(self._http), self.session_id))
198198

199199
@property
200200
def computer(self) -> AsyncComputerResource:
201201
from ...resources.browsers.computer import AsyncComputerResource
202202

203203
return cast(
204-
AsyncComputerResource, _BoundBrowserSubresource(AsyncComputerResource(self._metro), self.session_id)
204+
AsyncComputerResource, _BoundBrowserSubresource(AsyncComputerResource(self._http), self.session_id)
205205
)
206206

207207
@property
208208
def fs(self) -> AsyncFsResource:
209209
from ...resources.browsers.fs.fs import AsyncFsResource
210210

211-
return cast(AsyncFsResource, _BoundBrowserSubresource(AsyncFsResource(self._metro), self.session_id))
211+
return cast(AsyncFsResource, _BoundBrowserSubresource(AsyncFsResource(self._http), self.session_id))
212212

213213
@property
214214
def logs(self) -> AsyncLogsResource:
215215
from ...resources.browsers.logs import AsyncLogsResource
216216

217-
return cast(AsyncLogsResource, _BoundBrowserSubresource(AsyncLogsResource(self._metro), self.session_id))
217+
return cast(AsyncLogsResource, _BoundBrowserSubresource(AsyncLogsResource(self._http), self.session_id))
218218

219219
@property
220220
def playwright(self) -> AsyncPlaywrightResource:
221221
from ...resources.browsers.playwright import AsyncPlaywrightResource
222222

223223
return cast(
224-
AsyncPlaywrightResource, _BoundBrowserSubresource(AsyncPlaywrightResource(self._metro), self.session_id)
224+
AsyncPlaywrightResource, _BoundBrowserSubresource(AsyncPlaywrightResource(self._http), self.session_id)
225225
)
226226

227227
@property
228228
def replays(self) -> AsyncReplaysResource:
229229
from ...resources.browsers.replays import AsyncReplaysResource
230230

231-
return cast(AsyncReplaysResource, _BoundBrowserSubresource(AsyncReplaysResource(self._metro), self.session_id))
231+
return cast(AsyncReplaysResource, _BoundBrowserSubresource(AsyncReplaysResource(self._http), self.session_id))
232232

233233
async def request(
234234
self,
@@ -243,9 +243,7 @@ async def request(
243243
) -> httpx.Response:
244244
if json is not None and content is not None:
245245
raise TypeError("Passing both `json` and `content` is not supported")
246-
q: dict[str, object] = {"url": url}
247-
if params:
248-
q.update(dict(params))
246+
q: dict[str, object] = {**sanitize_curl_raw_params(params), "url": url}
249247
opts = FinalRequestOptions.construct(
250248
method=method.upper(),
251249
url="/curl/raw",
@@ -255,7 +253,7 @@ async def request(
255253
json_data=json,
256254
timeout=timeout,
257255
)
258-
return await self._metro.request(httpx.Response, opts)
256+
return await self._http.request(httpx.Response, opts)
259257

260258
@asynccontextmanager
261259
async def stream(
@@ -268,19 +266,18 @@ async def stream(
268266
params: Mapping[str, object] | None = None,
269267
timeout: float | Timeout | None | NotGiven = not_given,
270268
) -> AsyncIterator[httpx.Response]:
271-
q: dict[str, Any] = dict(self._metro.default_query)
269+
q: dict[str, Any] = dict(self._http.default_query)
270+
q.update(sanitize_curl_raw_params(params))
272271
q["url"] = url
273-
if params:
274-
q.update(dict(params))
275-
h = {k: v for k, v in self._metro.default_headers.items() if isinstance(v, str)}
272+
h = {k: v for k, v in self._http.default_headers.items() if isinstance(v, str)}
276273
if content is None:
277274
h.pop("Content-Type", None)
278275
if headers:
279276
h.update(headers)
280-
eff_timeout = self._metro.timeout if isinstance(timeout, NotGiven) else timeout
281-
async with self._metro._client.stream(
277+
eff_timeout = self._http.timeout if isinstance(timeout, NotGiven) else timeout
278+
async with self._http._client.stream(
282279
method.upper(),
283-
self._metro._prepare_url("/curl/raw"),
280+
self._http._prepare_url("/curl/raw"),
284281
params=q,
285282
headers=h,
286283
content=content,
@@ -291,21 +288,21 @@ async def stream(
291288

292289
def browser_scoped_from_browser(parent: Kernel, browser: Any) -> BrowserScopedClient:
293290
session_id = session_id_from_browser_like(browser)
294-
metro = base_url_from_browser_like(browser)
295-
if not metro:
291+
session_base = base_url_from_browser_like(browser)
292+
if not session_base:
296293
raise ValueError("browser.base_url is required for a browser-scoped client")
297294
jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser))
298295
if not jwt:
299-
raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests")
300-
return BrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt)
296+
raise ValueError("could not parse jwt from browser.cdp_ws_url; required for browser session HTTP")
297+
return BrowserScopedClient(parent, session_id=session_id, session_base_url=session_base, jwt=jwt)
301298

302299

303300
def async_browser_scoped_from_browser(parent: AsyncKernel, browser: Any) -> AsyncBrowserScopedClient:
304301
session_id = session_id_from_browser_like(browser)
305-
metro = base_url_from_browser_like(browser)
306-
if not metro:
302+
session_base = base_url_from_browser_like(browser)
303+
if not session_base:
307304
raise ValueError("browser.base_url is required for a browser-scoped client")
308305
jwt = jwt_from_cdp_ws_url(cdp_ws_url_from_browser_like(browser))
309306
if not jwt:
310-
raise ValueError("could not parse jwt from browser.cdp_ws_url; required for metro requests")
311-
return AsyncBrowserScopedClient(parent, session_id=session_id, metro_base_url=metro, jwt=jwt)
307+
raise ValueError("could not parse jwt from browser.cdp_ws_url; required for browser session HTTP")
308+
return AsyncBrowserScopedClient(parent, session_id=session_id, session_base_url=session_base, jwt=jwt)

src/kernel/lib/browser_scoped/util.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
from typing import Any, Mapping
44
from urllib.parse import parse_qs, urlparse
55

6+
# Query keys reserved for /curl/raw; user-supplied `params` must not override these.
7+
CURL_RAW_RESERVED_QUERY_KEYS: frozenset[str] = frozenset({"url", "jwt"})
8+
9+
10+
def sanitize_curl_raw_params(params: Mapping[str, object] | None) -> dict[str, object]:
11+
"""Drop reserved keys from user params so they cannot override the target URL or auth."""
12+
if not params:
13+
return {}
14+
return {k: v for k, v in dict(params).items() if k not in CURL_RAW_RESERVED_QUERY_KEYS}
15+
616

717
def jwt_from_cdp_ws_url(cdp_ws_url: str) -> str | None:
818
parsed = urlparse(cdp_ws_url)

src/kernel/types/browser_create_response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class BrowserCreateResponse(BaseModel):
3636
"""Websocket URL for WebDriver BiDi connections to the browser session"""
3737

3838
base_url: Optional[str] = None
39-
"""Metro-API HTTP base URL for this browser session."""
39+
"""HTTP base URL for this browser session (browser VM / session proxy)."""
4040

4141
browser_live_view_url: Optional[str] = None
4242
"""Remote URL for live viewing the browser session.

src/kernel/types/browser_list_response.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class BrowserListResponse(BaseModel):
3636
"""Websocket URL for WebDriver BiDi connections to the browser session"""
3737

3838
base_url: Optional[str] = None
39-
"""Metro-API HTTP base URL for this browser session."""
39+
"""HTTP base URL for this browser session (browser VM / session proxy)."""
4040

4141
browser_live_view_url: Optional[str] = None
4242
"""Remote URL for live viewing the browser session.

0 commit comments

Comments
 (0)