From fcabfe7629713d70c76bea9b837980e0ab042e76 Mon Sep 17 00:00:00 2001 From: Lars Buur Date: Mon, 6 Apr 2026 09:52:46 +0200 Subject: [PATCH 1/3] feat(modal): write extra tunnel URLs to /workspace/.tunnel-urls inside sandbox When extra tunnel ports are configured, Modal resolves the URLs externally but the coding agent inside the sandbox has no way to discover them. This writes the resolved URLs to /workspace/.tunnel-urls so the agent can read the file and share public preview URLs with the user. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/modal-infra/src/sandbox/manager.py | 19 +++++ .../modal-infra/tests/test_tunnel_ports.py | 79 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/modal-infra/src/sandbox/manager.py b/packages/modal-infra/src/sandbox/manager.py index 199bfc06..772db1c0 100644 --- a/packages/modal-infra/src/sandbox/manager.py +++ b/packages/modal-infra/src/sandbox/manager.py @@ -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 diff --git a/packages/modal-infra/tests/test_tunnel_ports.py b/packages/modal-infra/tests/test_tunnel_ports.py index 89eb7119..0bfb7e20 100644 --- a/packages/modal-infra/tests/test_tunnel_ports.py +++ b/packages/modal-infra/tests/test_tunnel_ports.py @@ -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", @@ -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, @@ -131,6 +137,79 @@ 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, + ): + _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"} + class TestCollectExposedPorts: """SandboxManager._collect_exposed_ports tests.""" From c44577e2a328ad56b63604e9998d39e0aa03912b Mon Sep 17 00:00:00 2001 From: Lars Buur Date: Mon, 6 Apr 2026 10:12:46 +0200 Subject: [PATCH 2/3] test: add proc.wait failure test for tunnel URL file writing Covers the case where sandbox.exec succeeds but proc.wait raises, verifying the exception is caught and tunnel URLs are still returned. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../modal-infra/tests/test_tunnel_ports.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/modal-infra/tests/test_tunnel_ports.py b/packages/modal-infra/tests/test_tunnel_ports.py index 0bfb7e20..2081eab4 100644 --- a/packages/modal-infra/tests/test_tunnel_ports.py +++ b/packages/modal-infra/tests/test_tunnel_ports.py @@ -210,6 +210,31 @@ async def test_tunnel_file_write_failure_does_not_raise(self): # Should still return the URLs despite the write failure assert extra == {3000: "https://tunnel-3000.example.com"} + @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, + ): + _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() + class TestCollectExposedPorts: """SandboxManager._collect_exposed_ports tests.""" From d200631f14cc755db0294e3c47d5924da9014be7 Mon Sep 17 00:00:00 2001 From: Lars Buur Date: Mon, 6 Apr 2026 10:22:01 +0200 Subject: [PATCH 3/3] test: assert warning log on tunnel file write failures Both exec-failure and wait-failure tests now verify that "tunnel.urls_write_failed" is logged as a warning. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../modal-infra/tests/test_tunnel_ports.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/modal-infra/tests/test_tunnel_ports.py b/packages/modal-infra/tests/test_tunnel_ports.py index 2081eab4..a87b109e 100644 --- a/packages/modal-infra/tests/test_tunnel_ports.py +++ b/packages/modal-infra/tests/test_tunnel_ports.py @@ -197,11 +197,14 @@ async def test_tunnel_file_write_failure_does_not_raise(self): 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, + 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] @@ -209,6 +212,8 @@ async def test_tunnel_file_write_failure_does_not_raise(self): # 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): @@ -221,11 +226,14 @@ async def test_tunnel_file_wait_failure_does_not_raise(self): sandbox.exec = MagicMock() sandbox.exec.aio = AsyncMock(return_value=proc) - with patch.object( - SandboxManager, - "_resolve_tunnels", - new_callable=AsyncMock, - return_value=tunnel_urls, + 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] @@ -234,6 +242,8 @@ async def test_tunnel_file_wait_failure_does_not_raise(self): # 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: