Skip to content

Commit ec72bcd

Browse files
authored
Merge pull request #157 from kernel/hypeship/tier1-direct-allowlist
Route computer and playwright calls directly to the VM
2 parents 2781384 + 111aa09 commit ec72bcd

3 files changed

Lines changed: 205 additions & 12 deletions

File tree

src/kernel/_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
strip_direct_vm_auth,
4242
rewrite_direct_vm_options,
4343
browser_routing_config_from_env,
44+
should_retry_stale_direct_vm_auth,
4445
maybe_evict_browser_route_from_response,
4546
maybe_populate_browser_route_cache_from_response,
4647
)
@@ -353,6 +354,13 @@ def _prepare_options(self, options: Any) -> Any:
353354
def _prepare_request(self, request: httpx.Request) -> None:
354355
strip_direct_vm_auth(request, cache=self.browser_route_cache)
355356

357+
@override
358+
def _should_retry(self, response: httpx.Response) -> bool:
359+
if should_retry_stale_direct_vm_auth(response):
360+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
361+
return True
362+
return super()._should_retry(response)
363+
356364
@override
357365
def _process_response(
358366
self,
@@ -722,6 +730,13 @@ async def _prepare_options(self, options: Any) -> Any:
722730
async def _prepare_request(self, request: httpx.Request) -> None:
723731
strip_direct_vm_auth(request, cache=self.browser_route_cache)
724732

733+
@override
734+
def _should_retry(self, response: httpx.Response) -> bool:
735+
if should_retry_stale_direct_vm_auth(response):
736+
maybe_evict_browser_route_from_response(response, cache=self.browser_route_cache)
737+
return True
738+
return super()._should_retry(response)
739+
725740
@override
726741
async def _process_response(
727742
self,

src/kernel/lib/browser_routing/routing.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def browser_routing_config_from_env() -> BrowserRoutingConfig:
4444
# Path prefixes eligible for direct-to-VM routing. "telemetry/stream" is
4545
# the live SSE endpoint (VM); "telemetry/events" is a historical read
4646
# served by the control plane (S2) and must NOT be here.
47-
return BrowserRoutingConfig(subresources=("curl", "telemetry/stream"))
47+
return BrowserRoutingConfig(subresources=("curl", "telemetry/stream", "computer", "playwright"))
4848
if raw.strip() == "":
4949
return BrowserRoutingConfig()
5050

@@ -69,6 +69,14 @@ def set(self, route: BrowserRoute) -> None:
6969
def delete(self, session_id: str) -> None:
7070
self._routes.pop(_normalize_session_id(session_id), None)
7171

72+
def delete_if_jwt(self, session_id: str, jwt: str) -> bool:
73+
key = _normalize_session_id(session_id)
74+
route = self._routes.get(key)
75+
if route is None or route.jwt != jwt.strip():
76+
return False
77+
del self._routes[key]
78+
return True
79+
7280
def values(self) -> list[BrowserRoute]:
7381
return list(self._routes.values())
7482

@@ -110,14 +118,19 @@ def maybe_populate_browser_route_cache_from_response(response: httpx.Response, *
110118

111119

112120
def maybe_evict_browser_route_from_response(response: httpx.Response, *, cache: BrowserRouteCache) -> None:
113-
if not response.is_success:
121+
if response.is_success:
122+
session_id = _session_id_to_evict_from_response(response)
123+
if session_id:
124+
cache.delete(session_id)
114125
return
115126

116-
session_id = _session_id_to_evict_from_response(response)
117-
if not session_id:
127+
if not is_stale_direct_vm_auth_response(response):
118128
return
119129

120-
cache.delete(session_id)
130+
jwt = str(response.request.url.params.get("jwt") or "").strip()
131+
session_id = _session_id_from_direct_vm_response(response, cache=cache)
132+
if session_id and jwt:
133+
cache.delete_if_jwt(session_id, jwt)
121134

122135

123136
def populate_browser_route_cache_from_value(value: object, *, cache: BrowserRouteCache) -> None:
@@ -161,6 +174,24 @@ def _session_id_to_evict_from_response(response: httpx.Response) -> str | None:
161174
return None
162175

163176

177+
def _session_id_from_direct_vm_response(response: httpx.Response, *, cache: BrowserRouteCache) -> str | None:
178+
raw = str(response.request.url)
179+
for route in cache.values():
180+
if raw.startswith(route.base_url.rstrip("/") + "/"):
181+
return route.session_id
182+
return None
183+
184+
185+
def is_stale_direct_vm_auth_response(response: httpx.Response) -> bool:
186+
if response.status_code not in {401, 403}:
187+
return False
188+
return bool(response.request.url.params.get("jwt"))
189+
190+
191+
def should_retry_stale_direct_vm_auth(response: httpx.Response) -> bool:
192+
return is_stale_direct_vm_auth_response(response)
193+
194+
164195
def _session_id_from_browser_delete_path(path: str) -> str | None:
165196
match = _BROWSER_DELETE_BY_ID_PATH.match(path)
166197
if match is None:

tests/test_browser_routing.py

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -390,9 +390,14 @@ def test_browser_route_from_browser_requires_base_url_and_jwt() -> None:
390390
assert browser_route_from_browser({**_fake_browser(), "cdp_ws_url": None}) is None
391391

392392

393-
def test_browser_routing_config_from_env_defaults_to_curl(monkeypatch: pytest.MonkeyPatch) -> None:
393+
def test_browser_routing_config_from_env_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
394394
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
395-
assert browser_routing_config_from_env().subresources == ("curl", "telemetry/stream")
395+
assert browser_routing_config_from_env().subresources == (
396+
"curl",
397+
"telemetry/stream",
398+
"computer",
399+
"playwright",
400+
)
396401

397402

398403
def test_direct_vm_routing_allowlist_segment_boundary() -> None:
@@ -401,13 +406,16 @@ def test_direct_vm_routing_allowlist_segment_boundary() -> None:
401406
# stream-prefixed-but-different path is not matched.
402407
from kernel.lib.browser_routing.routing import _matches_direct_vm_prefix
403408

404-
prefixes = ("curl", "telemetry/stream")
409+
prefixes = ("curl", "telemetry/stream", "computer", "playwright")
405410
assert _matches_direct_vm_prefix("telemetry/stream", prefixes) is True
406411
assert _matches_direct_vm_prefix("telemetry/stream/x", prefixes) is True
407412
assert _matches_direct_vm_prefix("telemetry/events", prefixes) is False
408413
assert _matches_direct_vm_prefix("telemetry/streaming-config", prefixes) is False
409414
assert _matches_direct_vm_prefix("telemetry", prefixes) is False
410415
assert _matches_direct_vm_prefix("curl/raw", prefixes) is True
416+
assert _matches_direct_vm_prefix("computer/screenshot", prefixes) is True
417+
assert _matches_direct_vm_prefix("playwright/execute", prefixes) is True
418+
assert _matches_direct_vm_prefix("process/exec", prefixes) is False
411419
assert _matches_direct_vm_prefix("fs/read", prefixes) is False
412420

413421

@@ -424,10 +432,8 @@ def test_rewrite_direct_vm_options_keeps_telemetry_events_on_control_plane() ->
424432
)
425433

426434
cache = BrowserRouteCache()
427-
cache.set(
428-
BrowserRoute(session_id="sess-1", base_url="http://browser-session.test/browser/kernel", jwt="token-abc")
429-
)
430-
config = BrowserRoutingConfig(subresources=("curl", "telemetry/stream"))
435+
cache.set(BrowserRoute(session_id="sess-1", base_url="http://browser-session.test/browser/kernel", jwt="token-abc"))
436+
config = BrowserRoutingConfig(subresources=("curl", "telemetry/stream", "computer", "playwright"))
431437

432438
events = rewrite_direct_vm_options(
433439
FinalRequestOptions(method="get", url="/browsers/sess-1/telemetry/events"), cache=cache, config=config
@@ -439,7 +445,148 @@ def test_rewrite_direct_vm_options_keeps_telemetry_events_on_control_plane() ->
439445
)
440446
assert str(stream.url).startswith("http://browser-session.test/browser/kernel/telemetry/stream")
441447

448+
screenshot = rewrite_direct_vm_options(
449+
FinalRequestOptions(method="post", url="/browsers/sess-1/computer/screenshot"), cache=cache, config=config
450+
)
451+
assert str(screenshot.url).startswith("http://browser-session.test/browser/kernel/computer/screenshot")
452+
453+
execute = rewrite_direct_vm_options(
454+
FinalRequestOptions(method="post", url="/browsers/sess-1/playwright/execute"), cache=cache, config=config
455+
)
456+
assert str(execute.url).startswith("http://browser-session.test/browser/kernel/playwright/execute")
457+
458+
process = rewrite_direct_vm_options(
459+
FinalRequestOptions(method="post", url="/browsers/sess-1/process/exec"), cache=cache, config=config
460+
)
461+
assert process.url == "/browsers/sess-1/process/exec"
462+
463+
fs_read = rewrite_direct_vm_options(
464+
FinalRequestOptions(method="get", url="/browsers/sess-1/fs/read_file"), cache=cache, config=config
465+
)
466+
assert fs_read.url == "/browsers/sess-1/fs/read_file"
467+
442468

443469
def test_browser_routing_config_from_env_empty_string_disables_routing(monkeypatch: pytest.MonkeyPatch) -> None:
444470
monkeypatch.setenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", "")
445471
assert browser_routing_config_from_env().subresources == ()
472+
473+
474+
@respx.mock
475+
def test_computer_screenshot_and_playwright_execute_route_to_vm_by_default(
476+
monkeypatch: pytest.MonkeyPatch,
477+
) -> None:
478+
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
479+
screenshot = respx.post("http://browser-session.test/browser/kernel/computer/screenshot").mock(
480+
return_value=httpx.Response(200, content=b"png", headers={"content-type": "image/png"})
481+
)
482+
execute = respx.post("http://browser-session.test/browser/kernel/playwright/execute").mock(
483+
return_value=httpx.Response(200, json={"success": True})
484+
)
485+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
486+
_cache_browser(client)
487+
client.browsers.computer.capture_screenshot("sess-1")
488+
out = client.browsers.playwright.execute("sess-1", code="return 1")
489+
490+
assert screenshot.called
491+
screenshot_req = cast(httpx.Request, cast(Any, screenshot.calls[0]).request)
492+
assert screenshot_req.url.params.get("jwt") == "token-abc"
493+
assert screenshot_req.headers.get("Authorization") is None
494+
assert execute.called
495+
execute_req = cast(httpx.Request, cast(Any, execute.calls[0]).request)
496+
assert execute_req.url.params.get("jwt") == "token-abc"
497+
assert execute_req.headers.get("Authorization") is None
498+
assert out.success is True
499+
500+
501+
@respx.mock
502+
def test_process_fs_and_telemetry_events_stay_on_api_origin_by_default(
503+
monkeypatch: pytest.MonkeyPatch,
504+
) -> None:
505+
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
506+
process = respx.post(f"{base_url}/browsers/sess-1/process/exec").mock(
507+
return_value=httpx.Response(200, json={"exit_code": 0, "stdout_b64": "", "stderr_b64": ""})
508+
)
509+
fs_read = respx.get(f"{base_url}/browsers/sess-1/fs/read_file").mock(
510+
return_value=httpx.Response(200, content=b"x", headers={"content-type": "application/octet-stream"})
511+
)
512+
events = respx.get(f"{base_url}/browsers/sess-1/telemetry/events").mock(return_value=httpx.Response(200, json=[]))
513+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
514+
_cache_browser(client)
515+
client.browsers.process.exec("sess-1", command="echo")
516+
client.browsers.fs.read_file("sess-1", path="/tmp/x")
517+
client.browsers.telemetry.events("sess-1")
518+
519+
assert process.called
520+
assert fs_read.called
521+
assert events.called
522+
523+
524+
@respx.mock
525+
def test_stale_direct_vm_jwt_evicts_cache_and_retries_control_plane(
526+
monkeypatch: pytest.MonkeyPatch,
527+
) -> None:
528+
monkeypatch.delenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", raising=False)
529+
530+
def _skip_retry_sleep(_self: object, **_kwargs: object) -> None:
531+
return None
532+
533+
monkeypatch.setattr("kernel._base_client.SyncAPIClient._sleep_for_retry", _skip_retry_sleep)
534+
vm = respx.post("http://browser-session.test/browser/kernel/computer/screenshot").mock(
535+
return_value=httpx.Response(401, text="Invalid JWT")
536+
)
537+
api = respx.post(f"{base_url}/browsers/sess-1/computer/screenshot").mock(
538+
return_value=httpx.Response(200, content=b"png", headers={"content-type": "image/png"})
539+
)
540+
with Kernel(base_url=base_url, api_key=api_key, _strict_response_validation=True) as client:
541+
_cache_browser(client)
542+
client.browsers.computer.capture_screenshot("sess-1")
543+
assert client.browser_route_cache.get("sess-1") is None
544+
545+
assert vm.called
546+
assert api.called
547+
api_req = cast(httpx.Request, cast(Any, api.calls[0]).request)
548+
assert api_req.headers.get("Authorization") == f"Bearer {api_key}"
549+
550+
551+
def test_stale_direct_vm_jwt_does_not_evict_refreshed_route() -> None:
552+
from kernel.lib.browser_routing.routing import maybe_evict_browser_route_from_response
553+
554+
cache = BrowserRouteCache()
555+
cache.set(
556+
BrowserRoute(
557+
session_id="sess-1",
558+
base_url="http://browser-session.test/browser/kernel",
559+
jwt="token-abc",
560+
)
561+
)
562+
cache.set(
563+
BrowserRoute(
564+
session_id="sess-1",
565+
base_url="http://browser-session.test/browser/kernel",
566+
jwt="jwt-FRESH",
567+
)
568+
)
569+
request = httpx.Request(
570+
"POST",
571+
"http://browser-session.test/browser/kernel/computer/screenshot?jwt=token-abc",
572+
)
573+
maybe_evict_browser_route_from_response(
574+
httpx.Response(401, text="Invalid JWT", request=request),
575+
cache=cache,
576+
)
577+
route = cache.get("sess-1")
578+
assert route is not None
579+
assert route.jwt == "jwt-FRESH"
580+
581+
582+
def test_stale_direct_vm_auth_retry_does_not_require_cached_route() -> None:
583+
from kernel.lib.browser_routing.routing import should_retry_stale_direct_vm_auth
584+
585+
request = httpx.Request(
586+
"POST",
587+
"http://browser-session.test/browser/kernel/computer/screenshot?jwt=token-abc",
588+
)
589+
response = httpx.Response(401, text="Invalid JWT", request=request)
590+
empty = BrowserRouteCache()
591+
assert should_retry_stale_direct_vm_auth(response) is True
592+
assert empty.get("sess-1") is None

0 commit comments

Comments
 (0)