From 7a6a813e7c05d624033a09a6118c6c39b77032f5 Mon Sep 17 00:00:00 2001 From: Naresh Date: Fri, 29 May 2026 13:05:42 +0100 Subject: [PATCH] Support Cloudflare exposed port URLs Resolve Cloudflare sandbox ports through the worker exposed-port endpoint and parse the returned URL into the SDK endpoint shape. Keep existing exposed_ports behavior intact while allowing callers to map sandbox ports to named Cloudflare endpoint prefixes through exposed_port_names. Ports with names are also tracked as exposed ports so existing resolution guards continue to apply, and provider or malformed responses surface as exposed-port errors. --- .../extensions/sandbox/cloudflare/sandbox.py | 68 +++++++--- tests/extensions/sandbox/test_cloudflare.py | 121 +++++++++++++++++- tests/sandbox/test_compatibility_guards.py | 3 +- 3 files changed, 174 insertions(+), 18 deletions(-) diff --git a/src/agents/extensions/sandbox/cloudflare/sandbox.py b/src/agents/extensions/sandbox/cloudflare/sandbox.py index d34d698294..6d4c840474 100644 --- a/src/agents/extensions/sandbox/cloudflare/sandbox.py +++ b/src/agents/extensions/sandbox/cloudflare/sandbox.py @@ -25,9 +25,10 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal -from urllib.parse import quote +from urllib.parse import quote, urlparse import aiohttp +from pydantic import Field as PydanticField from ....sandbox.errors import ( ConfigurationError, @@ -305,12 +306,14 @@ class CloudflareSandboxClientOptions(BaseSandboxClientOptions): worker_url: str api_key: str | None = None exposed_ports: tuple[int, ...] = () + exposed_port_names: dict[int, str] = PydanticField(default_factory=dict) def __init__( self, worker_url: str, api_key: str | None = None, exposed_ports: tuple[int, ...] = (), + exposed_port_names: dict[int, str] | None = None, *, type: Literal["cloudflare"] = "cloudflare", ) -> None: @@ -319,6 +322,7 @@ def __init__( worker_url=worker_url, api_key=api_key, exposed_ports=exposed_ports, + exposed_port_names=exposed_port_names or {}, ) @@ -326,6 +330,7 @@ class CloudflareSandboxSessionState(SandboxSessionState): type: Literal["cloudflare"] = "cloudflare" worker_url: str sandbox_id: str + exposed_port_names: dict[int, str] = PydanticField(default_factory=dict) @dataclass @@ -425,20 +430,47 @@ async def _validate_path_access(self, path: Path | str, *, for_write: bool = Fal return await self._validate_remote_path_access(path, for_write=for_write) async def _resolve_exposed_port(self, port: int) -> ExposedPortEndpoint: - """Cloudflare sandboxes do not yet support exposed port resolution.""" - raise ExposedPortUnavailableError( - port=port, - exposed_ports=self.state.exposed_ports, - reason="backend_unavailable", - context={ - "backend": "cloudflare", - "detail": ( - "The Cloudflare sandbox worker does not currently expose " - "a port-resolution endpoint. Exposed port support requires " - "a compatible worker deployment." - ), - }, - ) + http = self._session() + url = self._url(f"exposed-port/{port}") + request_kwargs: dict[str, object] = {"timeout": self._request_timeout()} + name = self.state.exposed_port_names.get(port) + if name is not None: + request_kwargs["json"] = {"name": name} + try: + async with http.post(url, **request_kwargs) as resp: + if resp.status != 200: + detail = await _read_cloudflare_response_body(resp) + raise ExposedPortUnavailableError( + port=port, + exposed_ports=self.state.exposed_ports, + reason="provider_error", + context=_cloudflare_error_context(status=resp.status, detail=detail), + ) + data = await resp.json(content_type=None) + endpoint_url = data.get("url") if isinstance(data, dict) else None + parsed_url = urlparse(str(endpoint_url)) + host = parsed_url.hostname + if not host or parsed_url.scheme not in {"http", "https"}: + raise ValueError(f"invalid exposed port URL: {endpoint_url!r}") + tls = parsed_url.scheme == "https" + return ExposedPortEndpoint( + host=host, + port=parsed_url.port or (443 if tls else 80), + tls=tls, + query=parsed_url.query, + ) + except ExposedPortUnavailableError: + raise + except Exception as e: + raise ExposedPortUnavailableError( + port=port, + exposed_ports=self.state.exposed_ports, + reason="provider_error", + context={ + "backend": "cloudflare", + "provider_error": str(e), + }, + ) from e async def mount_bucket( self, @@ -1435,13 +1467,17 @@ async def create( session_id = uuid.uuid4() snapshot_instance = resolve_snapshot(snapshot, str(session_id)) + exposed_ports = tuple( + dict.fromkeys((*options.exposed_ports, *options.exposed_port_names.keys())) + ) state = CloudflareSandboxSessionState( session_id=session_id, manifest=manifest, snapshot=snapshot_instance, worker_url=options.worker_url.rstrip("/"), sandbox_id=sandbox_id, - exposed_ports=options.exposed_ports, + exposed_ports=exposed_ports, + exposed_port_names=dict(options.exposed_port_names), ) inner = CloudflareSandboxSession( state=state, diff --git a/tests/extensions/sandbox/test_cloudflare.py b/tests/extensions/sandbox/test_cloudflare.py index 43c81665a9..ad79ce4f70 100644 --- a/tests/extensions/sandbox/test_cloudflare.py +++ b/tests/extensions/sandbox/test_cloudflare.py @@ -26,6 +26,7 @@ ErrorCode, ExecTimeoutError, ExecTransportError, + ExposedPortUnavailableError, InvalidManifestPathError, MountConfigError, PtySessionNotFoundError, @@ -206,11 +207,13 @@ def _make_state( worker_url: str = _WORKER_URL, sandbox_id: str = "abc123", manifest: Manifest | None = None, + exposed_ports: tuple[int, ...] = (), ) -> CloudflareSandboxSessionState: return CloudflareSandboxSessionState( session_id=uuid.uuid4(), manifest=manifest or Manifest(), snapshot=NoopSnapshot(id="snapshot"), + exposed_ports=exposed_ports, worker_url=worker_url, sandbox_id=sandbox_id, ) @@ -497,6 +500,32 @@ async def test_cloudflare_create_rejects_non_workspace_root() -> None: assert exc_info.value.context["manifest_root"] == "/tmp/app" +@pytest.mark.asyncio +async def test_cloudflare_create_unions_exposed_port_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_request_sandbox_id( + self: CloudflareSandboxClient, worker_url: str, api_key: str | None, **kwargs: object + ) -> str: + return "server2generated3id4base32" + + monkeypatch.setattr(CloudflareSandboxClient, "_request_sandbox_id", _fake_request_sandbox_id) + + client = CloudflareSandboxClient() + session = await client.create( + options=CloudflareSandboxClientOptions( + worker_url=_WORKER_URL, + exposed_ports=(8080,), + exposed_port_names={9090: "app"}, + ), + snapshot=None, + ) + + state = cast(CloudflareSandboxSessionState, session.state) + assert state.exposed_ports == (8080, 9090) + assert state.exposed_port_names == {9090: "app"} + + @pytest.mark.asyncio async def test_cloudflare_create_calls_post_sandbox_for_id( monkeypatch: pytest.MonkeyPatch, @@ -1179,6 +1208,96 @@ async def test_cloudflare_persist_rejects_truncated_streamed_archive_payload() - await sess.persist_workspace() +@pytest.mark.asyncio +async def test_cloudflare_resolve_exposed_port_without_name() -> None: + fake_http = _FakeHttp( + { + "POST /exposed-port/8080": _FakeResponse( + status=200, + json_body={ + "id": "quick-abc123", + "port": 8080, + "url": "https://abc.trycloudflare.com", + "hostname": "abc.trycloudflare.com", + "createdAt": "2026-05-29T00:00:00.000Z", + }, + ) + } + ) + sess = _make_session(state=_make_state(exposed_ports=(8080,)), fake_http=fake_http) + + endpoint = await sess.resolve_exposed_port(8080) + + assert endpoint.host == "abc.trycloudflare.com" + assert endpoint.port == 443 + assert endpoint.tls is True + assert endpoint.query == "" + assert fake_http.calls[-1]["method"] == "POST" + assert fake_http.calls[-1]["url"] == f"{_WORKER_URL}/v1/sandbox/abc123/exposed-port/8080" + assert "json" not in fake_http.calls[-1] + + +@pytest.mark.asyncio +async def test_cloudflare_resolve_exposed_port_with_name() -> None: + fake_http = _FakeHttp( + { + "POST /exposed-port/9090": _FakeResponse( + status=200, + json_body={ + "id": "11111111-2222-3333-4444-555555555555", + "port": 9090, + "url": "https://app.example.com", + "hostname": "app.example.com", + "name": "app", + "createdAt": "2026-05-29T00:00:00.000Z", + }, + ) + } + ) + state = _make_state(exposed_ports=(9090,)) + state.exposed_port_names = {9090: "app"} + sess = _make_session(state=state, fake_http=fake_http) + + endpoint = await sess.resolve_exposed_port(9090) + + assert endpoint.url_for("http") == "https://app.example.com/" + assert fake_http.calls[-1]["json"] == {"name": "app"} + + +@pytest.mark.asyncio +async def test_cloudflare_resolve_exposed_port_maps_provider_errors() -> None: + fake_http = _FakeHttp( + { + "POST /exposed-port/8080": _FakeResponse( + status=502, + json_body={"error": "exposed port failed", "code": "exposed_port_error"}, + ) + } + ) + sess = _make_session(state=_make_state(exposed_ports=(8080,)), fake_http=fake_http) + + with pytest.raises(ExposedPortUnavailableError) as exc_info: + await sess.resolve_exposed_port(8080) + + assert exc_info.value.context["backend"] == "cloudflare" + assert exc_info.value.context["http_status"] == 502 + assert "exposed port failed" in str(exc_info.value.context["provider_error"]) + + +@pytest.mark.asyncio +async def test_cloudflare_resolve_exposed_port_rejects_malformed_response() -> None: + fake_http = _FakeHttp( + {"POST /exposed-port/8080": _FakeResponse(status=200, json_body={"url": "not-a-url"})} + ) + sess = _make_session(state=_make_state(exposed_ports=(8080,)), fake_http=fake_http) + + with pytest.raises(ExposedPortUnavailableError) as exc_info: + await sess.resolve_exposed_port(8080) + + assert exc_info.value.context["backend"] == "cloudflare" + assert exc_info.value.context["provider_error"] == "invalid exposed port URL: 'not-a-url'" + + @pytest.mark.asyncio async def test_cloudflare_delete_calls_shutdown() -> None: fake_http = _FakeHttp() @@ -1397,7 +1516,7 @@ async def test_cloudflare_read_validates_path_access() -> None: async def _tracking_normalize(path: Path | str, *, for_write: bool = False) -> Path: calls.append((Path(path).as_posix(), for_write)) - # Fall back to synchronous normalize_path to avoid needing a real remote. + # Uses local path normalization; this test only tracks read() delegation. return sess.normalize_path(path, for_write=for_write) sess._validate_path_access = _tracking_normalize # type: ignore[method-assign] diff --git a/tests/sandbox/test_compatibility_guards.py b/tests/sandbox/test_compatibility_guards.py index 5a11e5bf77..7415e81348 100644 --- a/tests/sandbox/test_compatibility_guards.py +++ b/tests/sandbox/test_compatibility_guards.py @@ -453,7 +453,7 @@ def test_optional_sandbox_dataclass_constructor_field_order_is_stable( ( "agents.extensions.sandbox.cloudflare", "CloudflareSandboxClientOptions", - ("worker_url", "api_key", "exposed_ports"), + ("worker_url", "api_key", "exposed_ports", "exposed_port_names"), ), ( "agents.extensions.sandbox.daytona", @@ -638,6 +638,7 @@ def test_optional_sandbox_client_options_positional_field_order_is_stable( "workspace_root_ready", "worker_url", "sandbox_id", + "exposed_port_names", ), ), (