Skip to content

Commit b99f53c

Browse files
authored
fix route parsing (#73)
* fix route parsing * remove dupe function
1 parent ef489e0 commit b99f53c

7 files changed

Lines changed: 163 additions & 19 deletions

File tree

hyperbrowser/client/managers/sandboxes/shared.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
RUNTIME_SESSION_REFRESH_BUFFER_MS,
1818
normalize_network_error,
1919
parse_error_payload,
20+
runtime_base_url_session_id,
2021
)
2122

2223
DEFAULT_WATCH_TIMEOUT_MS = 60_000
@@ -84,13 +85,31 @@ def _normalize_exec_params(
8485
return _normalize_legacy_process_fields(normalized)
8586

8687

88+
def _resolve_sandbox_runtime_session_host(runtime, base_url) -> str:
89+
session_id_from_base_path = runtime_base_url_session_id(base_url.path)
90+
if session_id_from_base_path and base_url.hostname:
91+
return f"{session_id_from_base_path}.{base_url.hostname}"
92+
93+
runtime_host = str(getattr(runtime, "host", "") or "").strip()
94+
if runtime_host:
95+
parsed_host = urlsplit(runtime_host)
96+
if parsed_host.hostname:
97+
session_id_from_host_path = runtime_base_url_session_id(parsed_host.path)
98+
if session_id_from_host_path:
99+
return f"{session_id_from_host_path}.{parsed_host.hostname}"
100+
return parsed_host.hostname
101+
return runtime_host
102+
103+
return base_url.hostname or ""
104+
105+
87106
def _build_sandbox_exposed_url(runtime, port: int) -> str:
88107
parsed = urlsplit(runtime.base_url)
89-
hostname = parsed.hostname
90-
if not hostname:
108+
session_host = _resolve_sandbox_runtime_session_host(runtime, parsed)
109+
if not session_host:
91110
return runtime.base_url
92111

93-
exposed_host = f"{port}-{hostname}"
112+
exposed_host = f"{port}-{session_host}"
94113
netloc = exposed_host
95114
if parsed.port:
96115
netloc = f"{netloc}:{parsed.port}"
@@ -100,9 +119,7 @@ def _build_sandbox_exposed_url(runtime, port: int) -> str:
100119
credentials = f"{credentials}:{parsed.password}"
101120
netloc = f"{credentials}@{netloc}"
102121

103-
path = parsed.path or "/"
104-
105-
return urlunsplit((parsed.scheme, netloc, path, parsed.query, parsed.fragment))
122+
return urlunsplit((parsed.scheme, netloc, "/", "", ""))
106123

107124

108125
def _expires_within_buffer(expires_at: Optional[datetime]) -> bool:

hyperbrowser/sandbox_common.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,61 @@ def has_scheme(value: str) -> bool:
114114
return "://" in value
115115

116116

117+
def runtime_base_url_session_id(runtime_base_url: str) -> Optional[str]:
118+
parsed_base_url = urlsplit(runtime_base_url.strip())
119+
segments = [
120+
segment
121+
for segment in parsed_base_url.path.strip().strip("/").split("/")
122+
if segment
123+
]
124+
if len(segments) < 2 or segments[0] != "sandbox" or not segments[1].strip():
125+
return None
126+
return segments[1].strip()
127+
128+
129+
def should_prepend_sandbox_to_runtime_api(runtime_base_url: str) -> bool:
130+
return runtime_base_url_session_id(runtime_base_url) is None
131+
132+
133+
def normalize_runtime_api_path(pathname: str, prepend_sandbox: bool) -> str:
134+
trimmed = pathname.strip()
135+
if trimmed == "":
136+
return "/sandbox" if prepend_sandbox else "/"
137+
138+
absolute = trimmed if trimmed.startswith("/") else f"/{trimmed}"
139+
if prepend_sandbox:
140+
if absolute == "/sandbox" or absolute.startswith("/sandbox/"):
141+
return absolute
142+
return f"/sandbox{absolute}"
143+
144+
if absolute == "/sandbox":
145+
return "/"
146+
if absolute.startswith("/sandbox/"):
147+
return f"/{absolute[len('/sandbox/'):]}"
148+
return absolute
149+
150+
151+
def normalize_runtime_relative_path(base_url: str, path: str) -> str:
152+
trimmed = path.strip()
153+
if trimmed == "":
154+
return ""
155+
156+
parsed_path = urlsplit(trimmed)
157+
prepend_sandbox = should_prepend_sandbox_to_runtime_api(base_url)
158+
normalized_path = normalize_runtime_api_path(parsed_path.path, prepend_sandbox)
159+
relative_path = normalized_path.lstrip("/")
160+
return urlunsplit(
161+
("", "", relative_path, parsed_path.query, parsed_path.fragment)
162+
)
163+
164+
117165
def resolve_runtime_transport_target(
118166
base_url: str,
119167
path: str,
120168
runtime_proxy_override: Optional[str] = None,
121169
) -> RuntimeTransportTarget:
122170
normalized_base = base_url if base_url.endswith("/") else f"{base_url}/"
123-
url = urljoin(normalized_base, path.lstrip("/"))
171+
url = urljoin(normalized_base, normalize_runtime_relative_path(base_url, path))
124172

125173
if not runtime_proxy_override:
126174
return RuntimeTransportTarget(url=url)
@@ -151,7 +199,7 @@ def to_websocket_transport_target(
151199
runtime_proxy_override: Optional[str] = None,
152200
) -> RuntimeTransportTarget:
153201
normalized_base = base_url if base_url.endswith("/") else f"{base_url}/"
154-
url = urljoin(normalized_base, path.lstrip("/"))
202+
url = urljoin(normalized_base, normalize_runtime_relative_path(base_url, path))
155203
parts = urlsplit(url)
156204
scheme = parts.scheme
157205
if scheme == "https":

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "hyperbrowser"
3-
version = "0.90.2"
3+
version = "0.90.3"
44
description = "Python SDK for hyperbrowser"
55
authors = ["Nikhil Shahi <nshahi1998@gmail.com>"]
66
license = "MIT"

tests/sandbox/e2e/test_async_expose.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from urllib.parse import urlparse
23

34
import pytest
45

@@ -99,6 +100,8 @@ async def test_async_sandbox_expose_e2e():
99100
assert exposure.port == HTTP_PORT
100101
assert exposure.auth is False
101102
assert exposure.url == sandbox.get_exposed_url(HTTP_PORT)
103+
if "/sandbox/" in sandbox.runtime.base_url:
104+
assert urlparse(exposure.url).hostname.startswith(f"{HTTP_PORT}-{sandbox.id}.")
102105

103106
status, body = await _wait_for_http_response(
104107
exposure.url,

tests/sandbox/e2e/test_expose.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import time
2+
from urllib.parse import urlparse
23

34
from hyperbrowser.models import SandboxExecParams, SandboxExposeParams
45

@@ -92,6 +93,8 @@ def test_sandbox_expose_e2e():
9293
assert exposure.port == HTTP_PORT
9394
assert exposure.auth is False
9495
assert exposure.url == sandbox.get_exposed_url(HTTP_PORT)
96+
if "/sandbox/" in sandbox.runtime.base_url:
97+
assert urlparse(exposure.url).hostname.startswith(f"{HTTP_PORT}-{sandbox.id}.")
9598

9699
status, body = _wait_for_http_response(
97100
exposure.url,

tests/sandbox/e2e/test_runtime_transport.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ def test_runtime_transport_target_ignores_ambient_proxy_without_explicit_overrid
1818
assert target.host_header is None
1919

2020

21+
def test_runtime_transport_target_prepends_sandbox_for_session_host_relative_paths():
22+
target = resolve_runtime_transport_target(
23+
"https://session.example.dev:8443",
24+
"/exec?foo=bar",
25+
)
26+
27+
assert target.url == "https://session.example.dev:8443/sandbox/exec?foo=bar"
28+
assert target.host_header is None
29+
30+
2131
def test_runtime_transport_target_applies_explicit_proxy_override():
2232
target = resolve_runtime_transport_target(
2333
"https://session.example.dev:8443",
@@ -29,6 +39,26 @@ def test_runtime_transport_target_applies_explicit_proxy_override():
2939
assert target.host_header == "session.example.dev:8443"
3040

3141

42+
def test_runtime_transport_target_preserves_region_path_base_prefix():
43+
target = resolve_runtime_transport_target(
44+
"https://region.example.dev/sandbox/sbx_123",
45+
"/sandbox/exec?foo=bar",
46+
)
47+
48+
assert target.url == "https://region.example.dev/sandbox/sbx_123/exec?foo=bar"
49+
assert target.host_header is None
50+
51+
52+
def test_runtime_transport_target_avoids_double_sandbox_for_region_path_base():
53+
target = resolve_runtime_transport_target(
54+
"https://region.example.dev/sandbox/sbx_123",
55+
"/exec?foo=bar",
56+
)
57+
58+
assert target.url == "https://region.example.dev/sandbox/sbx_123/exec?foo=bar"
59+
assert target.host_header is None
60+
61+
3262
def test_runtime_websocket_target_applies_explicit_proxy_override():
3363
target = to_websocket_transport_target(
3464
"https://session.example.dev:8443",
@@ -42,3 +72,18 @@ def test_runtime_websocket_target_applies_explicit_proxy_override():
4272
)
4373
assert target.connect_host == "127.0.0.1"
4474
assert target.connect_port == 8090
75+
76+
77+
def test_runtime_websocket_target_preserves_region_path_base_prefix():
78+
target = to_websocket_transport_target(
79+
"https://region.example.dev/sandbox/sbx_123",
80+
"/sandbox/pty/pty_123/ws?sessionId=sandbox_123",
81+
"http://127.0.0.1:8090",
82+
)
83+
84+
assert (
85+
target.url
86+
== "wss://region.example.dev/sandbox/sbx_123/pty/pty_123/ws?sessionId=sandbox_123"
87+
)
88+
assert target.connect_host == "127.0.0.1"
89+
assert target.connect_port == 8090

tests/test_sandbox_wire_contract.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import httpx
22
import pytest
3+
from types import SimpleNamespace
34
import hyperbrowser.client.managers.async_manager.sandboxes.sandbox_terminal as async_terminal_module
45
import hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_terminal as sync_terminal_module
56

@@ -25,6 +26,7 @@
2526
from hyperbrowser.client.managers.sync_manager.sandboxes.sandbox_terminal import (
2627
SandboxTerminalApi,
2728
)
29+
from hyperbrowser.client.managers.sandboxes.shared import _build_sandbox_exposed_url
2830
from hyperbrowser.models import (
2931
CreateSandboxParams,
3032
SandboxDetail,
@@ -70,15 +72,15 @@
7072
"diskSizeMiB": 8192,
7173
"runtime": {
7274
"transport": "regional_proxy",
73-
"host": "runtime.example.com",
74-
"baseUrl": "https://runtime.example.com",
75+
"host": "https://runtime.example.com",
76+
"baseUrl": "https://runtime.example.com/sandbox/sbx_123",
7577
},
7678
"exposedPorts": [
7779
{
7880
"port": 3000,
7981
"auth": True,
80-
"url": "https://3000-runtime.example.com/",
81-
"browserUrl": "https://3000-runtime.example.com/_hb/auth?grant=test&next=%2F",
82+
"url": "https://3000-sbx_123.runtime.example.com/",
83+
"browserUrl": "https://3000-sbx_123.runtime.example.com/_hb/auth?grant=test&next=%2F",
8284
"browserUrlExpiresAt": "2026-03-12T02:00:00Z",
8385
}
8486
],
@@ -122,15 +124,15 @@
122124
"diskSizeMiB": 8192,
123125
"runtime": {
124126
"transport": "regional_proxy",
125-
"host": "runtime.example.com",
126-
"baseUrl": "https://runtime.example.com",
127+
"host": "https://runtime.example.com",
128+
"baseUrl": "https://runtime.example.com/sandbox/sbx_123",
127129
},
128130
"exposedPorts": [
129131
{
130132
"port": 3000,
131133
"auth": False,
132-
"url": "https://3000-runtime.example.com/",
133-
"browserUrl": "https://3000-runtime.example.com/",
134+
"url": "https://3000-sbx_123.runtime.example.com/",
135+
"browserUrl": "https://3000-sbx_123.runtime.example.com/",
134136
}
135137
],
136138
}
@@ -296,8 +298,8 @@
296298
EXPOSE_PAYLOAD = {
297299
"port": 3000,
298300
"auth": True,
299-
"url": "https://3000-runtime.example.com/",
300-
"browserUrl": "https://3000-runtime.example.com/_hb/auth?grant=test&next=%2F",
301+
"url": "https://3000-sbx_123.runtime.example.com/",
302+
"browserUrl": "https://3000-sbx_123.runtime.example.com/_hb/auth?grant=test&next=%2F",
301303
"browserUrlExpiresAt": "2026-03-12T02:00:00Z",
302304
}
303305

@@ -719,6 +721,7 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys():
719721
assert sandbox.memory_mib == 2048
720722
assert sandbox.disk_mib == 8192
721723
assert sandbox.exposed_ports[0].browser_url is not None
724+
assert sandbox.get_exposed_url(3000) == "https://3000-sbx_123.runtime.example.com/"
722725
assert expose_call["json"] == {"port": 3000, "auth": True}
723726
assert exposed.browser_url is not None
724727
assert expose_call["url"].endswith("/sandbox/sbx_123/expose")
@@ -733,6 +736,30 @@ def test_snapshot_summary_allows_missing_compatibility_tag():
733736
assert snapshot.compatibility_tag is None
734737

735738

739+
def test_build_sandbox_exposed_url_uses_runtime_base_path_session_id():
740+
runtime = SimpleNamespace(
741+
host="https://runtime.example.com",
742+
base_url="https://runtime.example.com/sandbox/sbx_123",
743+
)
744+
745+
assert (
746+
_build_sandbox_exposed_url(runtime, 3000)
747+
== "https://3000-sbx_123.runtime.example.com/"
748+
)
749+
750+
751+
def test_build_sandbox_exposed_url_uses_session_id_from_runtime_host_path():
752+
runtime = SimpleNamespace(
753+
host="https://runtime.example.com/sandbox/sbx_123",
754+
base_url="https://runtime.example.com",
755+
)
756+
757+
assert (
758+
_build_sandbox_exposed_url(runtime, 3000)
759+
== "https://3000-sbx_123.runtime.example.com/"
760+
)
761+
762+
736763
def test_sync_sandbox_runtime_apis_use_expected_wire_keys():
737764
transport = RecordingTransport()
738765

@@ -1017,6 +1044,7 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys():
10171044
assert sandbox.memory_mib == 2048
10181045
assert sandbox.disk_mib == 8192
10191046
assert sandbox.exposed_ports[0].browser_url is not None
1047+
assert sandbox.get_exposed_url(3000) == "https://3000-sbx_123.runtime.example.com/"
10201048
assert expose_call["json"] == {"port": 3000, "auth": True}
10211049
assert exposed.browser_url is not None
10221050
assert isinstance(unexposed, SandboxUnexposeResult)

0 commit comments

Comments
 (0)