Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 52 additions & 16 deletions src/agents/extensions/sandbox/cloudflare/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -319,13 +322,15 @@ def __init__(
worker_url=worker_url,
api_key=api_key,
exposed_ports=exposed_ports,
exposed_port_names=exposed_port_names or {},
)


class CloudflareSandboxSessionState(SandboxSessionState):
type: Literal["cloudflare"] = "cloudflare"
worker_url: str
sandbox_id: str
exposed_port_names: dict[int, str] = PydanticField(default_factory=dict)


@dataclass
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
121 changes: 120 additions & 1 deletion tests/extensions/sandbox/test_cloudflare.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
ErrorCode,
ExecTimeoutError,
ExecTransportError,
ExposedPortUnavailableError,
InvalidManifestPathError,
MountConfigError,
PtySessionNotFoundError,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion tests/sandbox/test_compatibility_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
),
),
(
Expand Down
Loading