From 815b20ce95949ac6bdb801354372af32a5647deb Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 13 Apr 2026 16:43:25 -0700 Subject: [PATCH 1/3] change runtime url --- .../client/managers/sandboxes/shared.py | 44 ++++++++++++++++--- tests/sandbox/e2e/test_runtime_transport.py | 28 ++++++++++++ tests/test_sandbox_wire_contract.py | 32 ++++++++------ 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 0bc85fb7..4e4603ef 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -86,11 +86,11 @@ def _normalize_exec_params( def _build_sandbox_exposed_url(runtime, port: int) -> str: parsed = urlsplit(runtime.base_url) - hostname = parsed.hostname - if not hostname: + session_host = _resolve_runtime_session_host(runtime, parsed) + if not session_host: return runtime.base_url - exposed_host = f"{port}-{hostname}" + exposed_host = f"{port}-{session_host}" netloc = exposed_host if parsed.port: netloc = f"{netloc}:{parsed.port}" @@ -100,9 +100,43 @@ def _build_sandbox_exposed_url(runtime, port: int) -> str: credentials = f"{credentials}:{parsed.password}" netloc = f"{credentials}@{netloc}" - path = parsed.path or "/" + return urlunsplit((parsed.scheme, netloc, "/", "", "")) - return urlunsplit((parsed.scheme, netloc, path, parsed.query, parsed.fragment)) + +def _resolve_runtime_session_host(runtime, parsed_base) -> Optional[str]: + session_id = _runtime_session_id_from_path(parsed_base.path) + if session_id and parsed_base.hostname: + return f"{session_id}.{parsed_base.hostname}" + + runtime_host = getattr(runtime, "host", None) + if isinstance(runtime_host, str): + runtime_host = runtime_host.strip() + else: + runtime_host = "" + + if not runtime_host: + return parsed_base.hostname + + parsed_runtime_host = urlsplit(runtime_host) + if parsed_runtime_host.hostname: + host_session_id = _runtime_session_id_from_path(parsed_runtime_host.path) + if host_session_id: + return f"{host_session_id}.{parsed_runtime_host.hostname}" + return parsed_runtime_host.hostname + + return runtime_host + + +def _runtime_session_id_from_path(path: str) -> Optional[str]: + segments = [segment for segment in path.strip("/").split("/") if segment] + if len(segments) < 2: + return None + if segments[0] != "sandbox": + return None + session_id = segments[1].strip() + if not session_id: + return None + return session_id def _expires_within_buffer(expires_at: Optional[datetime]) -> bool: diff --git a/tests/sandbox/e2e/test_runtime_transport.py b/tests/sandbox/e2e/test_runtime_transport.py index 5727a6d7..610da86f 100644 --- a/tests/sandbox/e2e/test_runtime_transport.py +++ b/tests/sandbox/e2e/test_runtime_transport.py @@ -42,3 +42,31 @@ def test_runtime_websocket_target_applies_explicit_proxy_override(): ) assert target.connect_host == "127.0.0.1" assert target.connect_port == 8090 + + +def test_runtime_transport_target_preserves_runtime_base_path(): + target = resolve_runtime_transport_target( + "https://region.example.dev/sandbox/sbx_123", + "/sandbox/exec?foo=bar", + ) + + assert ( + target.url + == "https://region.example.dev/sandbox/sbx_123/sandbox/exec?foo=bar" + ) + assert target.host_header is None + + +def test_runtime_websocket_target_preserves_runtime_base_path_with_override(): + target = to_websocket_transport_target( + "https://region.example.dev/sandbox/sbx_123", + "/sandbox/pty/pty_123/ws?sessionId=sandbox_123", + "http://127.0.0.1:8090", + ) + + assert ( + target.url + == "wss://region.example.dev/sandbox/sbx_123/sandbox/pty/pty_123/ws?sessionId=sandbox_123" + ) + assert target.connect_host == "127.0.0.1" + assert target.connect_port == 8090 diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 966f09e9..f97287d6 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -70,15 +70,15 @@ "diskSizeMiB": 8192, "runtime": { "transport": "regional_proxy", - "host": "runtime.example.com", - "baseUrl": "https://runtime.example.com", + "host": "https://runtime.example.com/sandbox/sbx_123", + "baseUrl": "https://runtime.example.com/sandbox/sbx_123", }, "exposedPorts": [ { "port": 3000, "auth": True, - "url": "https://3000-runtime.example.com/", - "browserUrl": "https://3000-runtime.example.com/_hb/auth?grant=test&next=%2F", + "url": "https://3000-sbx_123.runtime.example.com/", + "browserUrl": "https://3000-sbx_123.runtime.example.com/_hb/auth?grant=test&next=%2F", "browserUrlExpiresAt": "2026-03-12T02:00:00Z", } ], @@ -122,15 +122,15 @@ "diskSizeMiB": 8192, "runtime": { "transport": "regional_proxy", - "host": "runtime.example.com", - "baseUrl": "https://runtime.example.com", + "host": "https://runtime.example.com/sandbox/sbx_123", + "baseUrl": "https://runtime.example.com/sandbox/sbx_123", }, "exposedPorts": [ { "port": 3000, "auth": False, - "url": "https://3000-runtime.example.com/", - "browserUrl": "https://3000-runtime.example.com/", + "url": "https://3000-sbx_123.runtime.example.com/", + "browserUrl": "https://3000-sbx_123.runtime.example.com/", } ], } @@ -296,8 +296,8 @@ EXPOSE_PAYLOAD = { "port": 3000, "auth": True, - "url": "https://3000-runtime.example.com/", - "browserUrl": "https://3000-runtime.example.com/_hb/auth?grant=test&next=%2F", + "url": "https://3000-sbx_123.runtime.example.com/", + "browserUrl": "https://3000-sbx_123.runtime.example.com/_hb/auth?grant=test&next=%2F", "browserUrlExpiresAt": "2026-03-12T02:00:00Z", } @@ -621,7 +621,9 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): def test_sync_sandbox_control_manager_uses_expected_wire_keys(): client = FakeSyncClient() manager = SandboxManager(client) - runtime = type("Runtime", (), {"base_url": "https://runtime.example.com"})() + runtime = type( + "Runtime", (), {"base_url": "https://runtime.example.com/sandbox/sbx_123"} + )() listed = manager.list( SandboxListParams( @@ -919,7 +921,9 @@ def fake_exec(input, **kwargs): async def test_async_sandbox_control_manager_uses_expected_wire_keys(): client = FakeAsyncClient() manager = AsyncSandboxManager(client) - runtime = type("Runtime", (), {"base_url": "https://runtime.example.com"})() + runtime = type( + "Runtime", (), {"base_url": "https://runtime.example.com/sandbox/sbx_123"} + )() listed = await manager.list( SandboxListParams( @@ -1211,7 +1215,7 @@ def fake_connect(url, additional_headers=None, open_timeout=None, **kwargs): (), { "sandbox_id": "sbx_123", - "base_url": "https://runtime.example.com", + "base_url": "https://runtime.example.com/sandbox/sbx_123", "token": "tok", }, )(), @@ -1255,7 +1259,7 @@ async def get_connection_info(): (), { "sandbox_id": "sbx_123", - "base_url": "https://runtime.example.com", + "base_url": "https://runtime.example.com/sandbox/sbx_123", "token": "tok", }, )() From 42d7016c4e96e19e7117be5812d07c4e09d9d865 Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 13 Apr 2026 17:49:17 -0700 Subject: [PATCH 2/3] update path routing --- hyperbrowser/sandbox_common.py | 38 +++++++++++++++++++-- tests/sandbox/e2e/test_runtime_transport.py | 4 +-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/hyperbrowser/sandbox_common.py b/hyperbrowser/sandbox_common.py index e0e20298..df79a6b5 100644 --- a/hyperbrowser/sandbox_common.py +++ b/hyperbrowser/sandbox_common.py @@ -114,13 +114,47 @@ def has_scheme(value: str) -> bool: return "://" in value +def _session_scoped_runtime_base_path(path: str) -> bool: + segments = [segment for segment in path.strip("/").split("/") if segment] + return len(segments) >= 2 and segments[0] == "sandbox" and bool(segments[1].strip()) + + +def _strip_runtime_sandbox_prefix(path: str) -> str: + if path.startswith("/sandbox/"): + return "/" + path[len("/sandbox/") :] + if path == "/sandbox": + return "/" + if path.startswith("sandbox/"): + return path[len("sandbox/") :] + if path == "sandbox": + return "" + return path + + +def _normalize_runtime_relative_path(base_url: str, path: str) -> str: + trimmed = path.strip() + if not trimmed: + return "" + + parsed_base = urlsplit(base_url) + parsed_path = urlsplit(trimmed) + normalized_path = parsed_path.path + if _session_scoped_runtime_base_path(parsed_base.path): + normalized_path = _strip_runtime_sandbox_prefix(normalized_path) + + relative_path = normalized_path.lstrip("/") + query = f"?{parsed_path.query}" if parsed_path.query else "" + fragment = f"#{parsed_path.fragment}" if parsed_path.fragment else "" + return f"{relative_path}{query}{fragment}" + + def resolve_runtime_transport_target( base_url: str, path: str, runtime_proxy_override: Optional[str] = None, ) -> RuntimeTransportTarget: normalized_base = base_url if base_url.endswith("/") else f"{base_url}/" - url = urljoin(normalized_base, path.lstrip("/")) + url = urljoin(normalized_base, _normalize_runtime_relative_path(base_url, path)) if not runtime_proxy_override: return RuntimeTransportTarget(url=url) @@ -151,7 +185,7 @@ def to_websocket_transport_target( runtime_proxy_override: Optional[str] = None, ) -> RuntimeTransportTarget: normalized_base = base_url if base_url.endswith("/") else f"{base_url}/" - url = urljoin(normalized_base, path.lstrip("/")) + url = urljoin(normalized_base, _normalize_runtime_relative_path(base_url, path)) parts = urlsplit(url) scheme = parts.scheme if scheme == "https": diff --git a/tests/sandbox/e2e/test_runtime_transport.py b/tests/sandbox/e2e/test_runtime_transport.py index 610da86f..0c826aec 100644 --- a/tests/sandbox/e2e/test_runtime_transport.py +++ b/tests/sandbox/e2e/test_runtime_transport.py @@ -52,7 +52,7 @@ def test_runtime_transport_target_preserves_runtime_base_path(): assert ( target.url - == "https://region.example.dev/sandbox/sbx_123/sandbox/exec?foo=bar" + == "https://region.example.dev/sandbox/sbx_123/exec?foo=bar" ) assert target.host_header is None @@ -66,7 +66,7 @@ def test_runtime_websocket_target_preserves_runtime_base_path_with_override(): assert ( target.url - == "wss://region.example.dev/sandbox/sbx_123/sandbox/pty/pty_123/ws?sessionId=sandbox_123" + == "wss://region.example.dev/sandbox/sbx_123/pty/pty_123/ws?sessionId=sandbox_123" ) assert target.connect_host == "127.0.0.1" assert target.connect_port == 8090 From 0c6754980f89ab0ce3f4a5dca3b13834b8ada6fb Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 13 Apr 2026 18:14:17 -0700 Subject: [PATCH 3/3] determine runtime before parse --- .../async_manager/sandboxes/sandbox_files.py | 1 + .../sandboxes/sandbox_terminal.py | 1 + .../sandboxes/sandbox_transport.py | 2 + .../sync_manager/sandboxes/sandbox_files.py | 1 + .../sandboxes/sandbox_terminal.py | 1 + .../sandboxes/sandbox_transport.py | 2 + hyperbrowser/sandbox_common.py | 76 ++++++++++++++----- tests/sandbox/e2e/test_runtime_transport.py | 23 ++++++ tests/test_sandbox_wire_contract.py | 12 ++- 9 files changed, 96 insertions(+), 23 deletions(-) diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py index d807224f..db9501f4 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py @@ -110,6 +110,7 @@ async def events( connection.base_url, f"/sandbox/files/watch/{self.id}/{route}?{query}", self._runtime_proxy_override, + connection.sandbox_id, ) headers = build_headers(connection.token, host_header=target.host_header) connect_kwargs = {} diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py index 3d258415..c1c7dd8c 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py @@ -176,6 +176,7 @@ async def attach( connection.base_url, f"/sandbox/pty/{self.id}/ws?{query}", self._runtime_proxy_override, + connection.sandbox_id, ) headers = build_headers(connection.token, host_header=target.host_header) connect_kwargs = {} diff --git a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py index a3061759..24ac29c2 100644 --- a/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py @@ -194,6 +194,7 @@ async def _send( connection.base_url, request_path, self._runtime_proxy_override, + connection.sandbox_id, ) merged_headers = build_headers(connection.token, headers, target.host_header) client = httpx.AsyncClient(timeout=self._timeout) @@ -230,6 +231,7 @@ async def _send_stream( connection.base_url, request_path, self._runtime_proxy_override, + connection.sandbox_id, ) headers = build_headers( connection.token, diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py index ebaaca57..eaa61161 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py @@ -109,6 +109,7 @@ def events( connection.base_url, f"/sandbox/files/watch/{self.id}/{route}?{query}", self._runtime_proxy_override, + connection.sandbox_id, ) headers = build_headers(connection.token, host_header=target.host_header) connect_kwargs = {} diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_terminal.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_terminal.py index 958ebce7..7e00a09f 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_terminal.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_terminal.py @@ -173,6 +173,7 @@ def attach(self, cursor: Optional[int] = None) -> SandboxTerminalConnection: connection.base_url, f"/sandbox/pty/{self.id}/ws?{query}", self._runtime_proxy_override, + connection.sandbox_id, ) headers = build_headers(connection.token, host_header=target.host_header) connect_kwargs = {} diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py index d51d2647..ae09beed 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py @@ -192,6 +192,7 @@ def _send( connection.base_url, request_path, self._runtime_proxy_override, + connection.sandbox_id, ) merged_headers = build_headers(connection.token, headers, target.host_header) client = httpx.Client(timeout=self._timeout) @@ -228,6 +229,7 @@ def _send_stream( connection.base_url, request_path, self._runtime_proxy_override, + connection.sandbox_id, ) headers = build_headers( connection.token, diff --git a/hyperbrowser/sandbox_common.py b/hyperbrowser/sandbox_common.py index df79a6b5..cb12d643 100644 --- a/hyperbrowser/sandbox_common.py +++ b/hyperbrowser/sandbox_common.py @@ -114,33 +114,63 @@ def has_scheme(value: str) -> bool: return "://" in value -def _session_scoped_runtime_base_path(path: str) -> bool: - segments = [segment for segment in path.strip("/").split("/") if segment] - return len(segments) >= 2 and segments[0] == "sandbox" and bool(segments[1].strip()) +def _runtime_base_url_session_id(base_url: str) -> Optional[str]: + parsed_base = urlsplit(base_url) + segments = [segment for segment in parsed_base.path.strip("/").split("/") if segment] + if len(segments) < 2: + return None + if segments[0] != "sandbox": + return None + session_id = segments[1].strip() + if not session_id: + return None + return session_id + + +def should_prepend_sandbox_to_runtime_api( + runtime_base_url: str, sandbox_id: Optional[str] = None +) -> bool: + path_session_id = _runtime_base_url_session_id(runtime_base_url) + if path_session_id is None: + return True + + trimmed_sandbox_id = (sandbox_id or "").strip() + if trimmed_sandbox_id and trimmed_sandbox_id != path_session_id: + # Keep path-routed behavior when local metadata is stale. + return False + return False + + +def _normalize_runtime_api_path(path: str, prepend_sandbox: bool) -> str: + trimmed = path.strip() + if not trimmed: + return "/sandbox" if prepend_sandbox else "/" + absolute = trimmed if trimmed.startswith("/") else f"/{trimmed}" + if prepend_sandbox: + if absolute == "/sandbox" or absolute.startswith("/sandbox/"): + return absolute + return f"/sandbox{absolute}" -def _strip_runtime_sandbox_prefix(path: str) -> str: - if path.startswith("/sandbox/"): - return "/" + path[len("/sandbox/") :] - if path == "/sandbox": + if absolute == "/sandbox": return "/" - if path.startswith("sandbox/"): - return path[len("sandbox/") :] - if path == "sandbox": - return "" - return path + if absolute.startswith("/sandbox/"): + return "/" + absolute[len("/sandbox/") :] + return absolute -def _normalize_runtime_relative_path(base_url: str, path: str) -> str: +def _normalize_runtime_relative_path( + base_url: str, path: str, sandbox_id: Optional[str] = None +) -> str: trimmed = path.strip() if not trimmed: return "" - parsed_base = urlsplit(base_url) parsed_path = urlsplit(trimmed) - normalized_path = parsed_path.path - if _session_scoped_runtime_base_path(parsed_base.path): - normalized_path = _strip_runtime_sandbox_prefix(normalized_path) + normalized_path = _normalize_runtime_api_path( + parsed_path.path, + should_prepend_sandbox_to_runtime_api(base_url, sandbox_id), + ) relative_path = normalized_path.lstrip("/") query = f"?{parsed_path.query}" if parsed_path.query else "" @@ -152,9 +182,13 @@ def resolve_runtime_transport_target( base_url: str, path: str, runtime_proxy_override: Optional[str] = None, + sandbox_id: Optional[str] = None, ) -> RuntimeTransportTarget: normalized_base = base_url if base_url.endswith("/") else f"{base_url}/" - url = urljoin(normalized_base, _normalize_runtime_relative_path(base_url, path)) + url = urljoin( + normalized_base, + _normalize_runtime_relative_path(base_url, path, sandbox_id), + ) if not runtime_proxy_override: return RuntimeTransportTarget(url=url) @@ -183,9 +217,13 @@ def to_websocket_transport_target( base_url: str, path: str, runtime_proxy_override: Optional[str] = None, + sandbox_id: Optional[str] = None, ) -> RuntimeTransportTarget: normalized_base = base_url if base_url.endswith("/") else f"{base_url}/" - url = urljoin(normalized_base, _normalize_runtime_relative_path(base_url, path)) + url = urljoin( + normalized_base, + _normalize_runtime_relative_path(base_url, path, sandbox_id), + ) parts = urlsplit(url) scheme = parts.scheme if scheme == "https": diff --git a/tests/sandbox/e2e/test_runtime_transport.py b/tests/sandbox/e2e/test_runtime_transport.py index 0c826aec..9657d49a 100644 --- a/tests/sandbox/e2e/test_runtime_transport.py +++ b/tests/sandbox/e2e/test_runtime_transport.py @@ -18,6 +18,16 @@ def test_runtime_transport_target_ignores_ambient_proxy_without_explicit_overrid assert target.host_header is None +def test_runtime_transport_target_prepends_sandbox_for_session_host_runtime_relative_path(): + target = resolve_runtime_transport_target( + "https://session.example.dev:8443", + "/exec?foo=bar", + ) + + assert target.url == "https://session.example.dev:8443/sandbox/exec?foo=bar" + assert target.host_header is None + + def test_runtime_transport_target_applies_explicit_proxy_override(): target = resolve_runtime_transport_target( "https://session.example.dev:8443", @@ -57,6 +67,19 @@ def test_runtime_transport_target_preserves_runtime_base_path(): assert target.host_header is None +def test_runtime_transport_target_does_not_double_prefix_for_region_path_runtime_relative_path(): + target = resolve_runtime_transport_target( + "https://region.example.dev/sandbox/sbx_123", + "/exec?foo=bar", + ) + + assert ( + target.url + == "https://region.example.dev/sandbox/sbx_123/exec?foo=bar" + ) + assert target.host_header is None + + def test_runtime_websocket_target_preserves_runtime_base_path_with_override(): target = to_websocket_transport_target( "https://region.example.dev/sandbox/sbx_123", diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index f97287d6..e63bdc9b 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -70,7 +70,7 @@ "diskSizeMiB": 8192, "runtime": { "transport": "regional_proxy", - "host": "https://runtime.example.com/sandbox/sbx_123", + "host": "https://runtime.example.com", "baseUrl": "https://runtime.example.com/sandbox/sbx_123", }, "exposedPorts": [ @@ -122,7 +122,7 @@ "diskSizeMiB": 8192, "runtime": { "transport": "regional_proxy", - "host": "https://runtime.example.com/sandbox/sbx_123", + "host": "https://runtime.example.com", "baseUrl": "https://runtime.example.com/sandbox/sbx_123", }, "exposedPorts": [ @@ -1195,8 +1195,9 @@ class DummyTarget: connect_host = None connect_port = None - def fake_target(base_url, path, runtime_proxy_override): + def fake_target(base_url, path, runtime_proxy_override, sandbox_id=None): captured["path"] = path + captured["sandbox_id"] = sandbox_id return DummyTarget() def fake_connect(url, additional_headers=None, open_timeout=None, **kwargs): @@ -1226,6 +1227,7 @@ def fake_connect(url, additional_headers=None, open_timeout=None, **kwargs): assert "cursor=7" in captured["path"] assert "cursor=7" in captured["url"] + assert captured["sandbox_id"] == "sbx_123" @pytest.mark.anyio @@ -1240,8 +1242,9 @@ class DummyTarget: connect_host = None connect_port = None - def fake_target(base_url, path, runtime_proxy_override): + def fake_target(base_url, path, runtime_proxy_override, sandbox_id=None): captured["path"] = path + captured["sandbox_id"] = sandbox_id return DummyTarget() async def fake_connect(url, additional_headers=None, open_timeout=None, **kwargs): @@ -1274,3 +1277,4 @@ async def get_connection_info(): assert "cursor=7" in captured["path"] assert "cursor=7" in captured["url"] + assert captured["sandbox_id"] == "sbx_123"