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/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/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 e0e20298..cb12d643 100644 --- a/hyperbrowser/sandbox_common.py +++ b/hyperbrowser/sandbox_common.py @@ -114,13 +114,81 @@ def has_scheme(value: str) -> bool: return "://" in value +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}" + + if absolute == "/sandbox": + return "/" + if absolute.startswith("/sandbox/"): + return "/" + absolute[len("/sandbox/") :] + return absolute + + +def _normalize_runtime_relative_path( + base_url: str, path: str, sandbox_id: Optional[str] = None +) -> str: + trimmed = path.strip() + if not trimmed: + return "" + + parsed_path = urlsplit(trimmed) + 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 "" + 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, + sandbox_id: 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, sandbox_id), + ) if not runtime_proxy_override: return RuntimeTransportTarget(url=url) @@ -149,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, path.lstrip("/")) + 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 5727a6d7..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", @@ -42,3 +52,44 @@ 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/exec?foo=bar" + ) + 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", + "/sandbox/pty/pty_123/ws?sessionId=sandbox_123", + "http://127.0.0.1:8090", + ) + + assert ( + target.url + == "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 diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 966f09e9..e63bdc9b 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", + "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", + "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( @@ -1191,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): @@ -1211,7 +1216,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", }, )(), @@ -1222,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 @@ -1236,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): @@ -1255,7 +1262,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", }, )() @@ -1270,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"