Skip to content
Open
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
19 changes: 19 additions & 0 deletions packages/modal-infra/src/sandbox/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,25 @@ async def _resolve_and_setup_tunnels(
ttyd_url = resolved.pop(TTYD_PROXY_PORT, None)
extra_urls = resolved if resolved else None

# Write tunnel URLs into the sandbox so the coding agent can discover them
if extra_urls:
lines = [f"{port} {url}" for port, url in sorted(extra_urls.items())]
content = "\n".join(lines) + "\n"
try:
proc = await sandbox.exec.aio(
"bash",
"-c",
f"cat > /workspace/.tunnel-urls << 'EOF'\n{content}EOF",
)
await proc.wait.aio()
log.info(
"tunnel.urls_written",
sandbox_id=sandbox_id,
ports=list(extra_urls.keys()),
)
except Exception as e:
log.warn("tunnel.urls_write_failed", sandbox_id=sandbox_id, exc=e)

return code_server_url, ttyd_url, extra_urls

@staticmethod
Expand Down
114 changes: 114 additions & 0 deletions packages/modal-infra/tests/test_tunnel_ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ async def test_returns_none_none_none_for_no_ports(self):
async def test_resolves_extra_ports(self):
tunnel_urls = {3000: "https://tunnel-3000.example.com"}

proc = AsyncMock()
sandbox = MagicMock()
sandbox.exec = MagicMock()
sandbox.exec.aio = AsyncMock(return_value=proc)
with patch.object(
SandboxManager,
"_resolve_tunnels",
Expand All @@ -115,7 +118,10 @@ async def test_splits_code_server_from_extra_ports(self):
3000: "https://tunnel-3000.example.com",
}

proc = AsyncMock()
sandbox = MagicMock()
sandbox.exec = MagicMock()
sandbox.exec.aio = AsyncMock(return_value=proc)

with patch.object(
SandboxManager,
Expand All @@ -131,6 +137,114 @@ async def test_splits_code_server_from_extra_ports(self):
assert ttyd_url is None
assert extra == {3000: "https://tunnel-3000.example.com"}

@pytest.mark.asyncio
async def test_writes_tunnel_urls_to_sandbox_filesystem(self):
"""Tunnel URLs should be written to /workspace/.tunnel-urls inside the sandbox."""
tunnel_urls = {
3000: "https://tunnel-3000.example.com",
3001: "https://tunnel-3001.example.com",
}

proc = AsyncMock()
sandbox = MagicMock()
sandbox.exec = MagicMock()
sandbox.exec.aio = AsyncMock(return_value=proc)

with patch.object(
SandboxManager,
"_resolve_tunnels",
new_callable=AsyncMock,
return_value=tunnel_urls,
):
await SandboxManager._resolve_and_setup_tunnels(
sandbox, "sb-1", False, False, [3000, 3001]
)

sandbox.exec.aio.assert_called_once()
args = sandbox.exec.aio.call_args
cmd = args[0][2] # the bash -c argument
assert "3000 https://tunnel-3000.example.com" in cmd
assert "3001 https://tunnel-3001.example.com" in cmd
assert "/workspace/.tunnel-urls" in cmd
proc.wait.aio.assert_awaited_once()

@pytest.mark.asyncio
async def test_does_not_write_file_when_no_extra_urls(self):
"""No file should be written when there are no extra tunnel URLs."""
sandbox = MagicMock()
sandbox.exec = MagicMock()
sandbox.exec.aio = AsyncMock()

with patch.object(
SandboxManager,
"_resolve_tunnels",
new_callable=AsyncMock,
return_value={},
):
_, _, extra = await SandboxManager._resolve_and_setup_tunnels(
sandbox, "sb-1", False, False, [3000]
)

assert extra is None
sandbox.exec.aio.assert_not_called()

@pytest.mark.asyncio
async def test_tunnel_file_write_failure_does_not_raise(self):
"""If writing the tunnel file fails, it should log a warning but not raise."""
tunnel_urls = {3000: "https://tunnel-3000.example.com"}

sandbox = MagicMock()
sandbox.exec = MagicMock()
sandbox.exec.aio = AsyncMock(side_effect=Exception("exec failed"))

with (
patch.object(
SandboxManager,
"_resolve_tunnels",
new_callable=AsyncMock,
return_value=tunnel_urls,
),
patch("src.sandbox.manager.log") as mock_log,
):
_cs_url, _ttyd_url, extra = await SandboxManager._resolve_and_setup_tunnels(
sandbox, "sb-1", False, False, [3000]
)

# Should still return the URLs despite the write failure
assert extra == {3000: "https://tunnel-3000.example.com"}
mock_log.warn.assert_called_once()
assert mock_log.warn.call_args[0][0] == "tunnel.urls_write_failed"

@pytest.mark.asyncio
async def test_tunnel_file_wait_failure_does_not_raise(self):
"""If proc.wait raises after exec succeeds, it should log a warning but not raise."""
tunnel_urls = {3000: "https://tunnel-3000.example.com"}

proc = AsyncMock()
proc.wait.aio = AsyncMock(side_effect=Exception("wait failed"))
sandbox = MagicMock()
sandbox.exec = MagicMock()
sandbox.exec.aio = AsyncMock(return_value=proc)

with (
patch.object(
SandboxManager,
"_resolve_tunnels",
new_callable=AsyncMock,
return_value=tunnel_urls,
),
patch("src.sandbox.manager.log") as mock_log,
):
_cs_url, _ttyd_url, extra = await SandboxManager._resolve_and_setup_tunnels(
sandbox, "sb-1", False, False, [3000]
)

# Should still return the URLs despite the wait failure
assert extra == {3000: "https://tunnel-3000.example.com"}
sandbox.exec.aio.assert_called_once()
mock_log.warn.assert_called_once()
assert mock_log.warn.call_args[0][0] == "tunnel.urls_write_failed"


class TestCollectExposedPorts:
"""SandboxManager._collect_exposed_ports tests."""
Expand Down