Repository navigation
fix(sandbox): local deploys publish their ports on loopback, not every interface - #15
Merged
Merged
Conversation
…y interface Every port a local deploy published (gateway, MCP servers and sidecars, pgweb, db-mcp, the A2A agent, a linked agent, run_docker_container ports) bound all interfaces, so each was reachable from the LAN; on Rancher Desktop lima's forwarder listened on *:<port>. A sandbox now carries host_ips, the host IPs its published ports bind to. The base Sandbox keeps () and publishes on every interface as before, so Modal, Modal VM and E2B are unchanged: their tunnels reach the port from outside the VM. LocalSandbox returns 127.0.0.1 and, on Linux, also the default bridge's gateway, read once from `docker network inspect bridge`: host.docker.internal is host-gateway there, and a loopback-only port is unreachable from it. port_bindings() renders one [ip:]host:container spec per IP for compose and docker run, and every publish site goes through it, the snapshot reload's regenerated compose included. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
earakely-scale
requested review from
a team and
polakamtejas
as code owners
September 30, 2026 18:00
… default bridge On Linux the ports were also bound to the default bridge's gateway, which Docker Desktop can't bind: it publishes through a host-side proxy, and that address only exists inside its VM (docker/desktop-feedback#485). The bridge address is now added only when `docker info` doesn't report Docker Desktop. Rancher Desktop and Docker Engine still get it, since there containers reach the host through it. A daemon with no default bridge now gets loopback alone instead of an error, and both docker queries time out after 30 s rather than hanging a deploy. The compose port guard also reads single-quoted and bare mappings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the process The host IPs are cached per process, and a failed `docker info` read as "not Docker Desktop, no bridge", so one transient daemon error kept every later agent on Linux off the bridge address and away from its gateway. `docker info` now raises when it fails, which the cache doesn't keep, and only a missing default bridge, reported by a daemon that has just answered, falls back to loopback. The port-mapping guard test also pins an `ip:host:container` entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… every docker failure raises The bridge query failed open: any error from `docker network inspect bridge`, not only a missing network, gave loopback alone, and the cache kept it. `docker network ls` answers a missing network with nothing rather than an error, so the bridge is now looked up there first. Every docker query now raises when it fails, and the cache never keeps a failure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1 URLs, name docker's error pgweb and db-mcp are opened only from the host. So on native Linux they no longer publish on the Docker bridge address, where a machine that routes the bridge subnet through the host could reach them. The gateway and agent keep it, because containers reach them there. Local URLs now use 127.0.0.1 instead of localhost. The ports are published on IPv4 loopback, so a client that tries ::1 first, or only ::1, would otherwise miss them. A failed docker query now raises with docker's own message. The README's trust model says where local ports are published. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ports-on-loopback # Conflicts: # README.md
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
agutmanstein-scale
approved these changes
Sep 30, 2026
| " - PGWEB_LOCK_SESSION=true", | ||
| " ports:", | ||
| f' - "{publish(DB_WEB_PORT)}:{DB_WEB_PORT}"', | ||
| *(f' - "{spec}"' for spec in port_bindings(host_ips, publish(DB_WEB_PORT), DB_WEB_PORT)), |
Collaborator
There was a problem hiding this comment.
Does this work? It is no longer doing IP:PORT right? Or am I misreading the python
Collaborator
Author
There was a problem hiding this comment.
It still writes the full mapping; port_bindings() builds it now instead of the literal. It returns one [ip:]host:container spec per host IP, and the generator turns each into a - "…" line:
port_bindings(("127.0.0.1", "172.17.0.1"), 51802, 8081) # ['127.0.0.1:51802:8081', '172.17.0.1:51802:8081']
port_bindings((), 8081, 8081) # ['8081:8081'] (remote VMs, unchanged)For pgweb and db-mcp, host_ips[:1] keeps only loopback, so the rendered line is - "127.0.0.1:<host port>:8081".
On a real deploy at this head on native Linux, Docker bound pgweb as 8081/tcp -> 127.0.0.1:49573 and it answered 200. test_gateway_compose_publishes_only_on_the_host_ips asserts the exact (ip, host, container) triples. Fair point that the literal read more clearly, though.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Local deploys now publish their ports on
127.0.0.1only, plus the Docker bridge gateway on Linux, instead of on every interface. Remote providers are unchanged.The seam.
Sandbox.host_ipsis the host IPs a sandbox's published ports bind to. The base class keeps(), which publishes on every interface as before, so Modal, Modal VM and E2B render exactly what they did: their tunnels reach the port from outside the VM.LocalSandbox.host_ipsreturns127.0.0.1, and on Linux also the default bridge's gateway, read once per process fromdocker network inspect bridge. Two Linux setups get loopback alone:docker inforeportingDocker Desktop. It publishes through a host-side proxy that can't bind the bridge address, which only exists inside its VM (docker/desktop-feedback#485).docker network ls, which answers a missing network with nothing rather than an error.Every docker query times out after 30 s and raises with docker's own message when it fails. The cache never keeps a failure, so a daemon that was briefly down is asked again.
pgweb and db-mcp stay on loopback. Only the host opens them; nothing hands their URLs to a container. So even on Linux they skip the bridge address, and only the gateway and the agent keep it.
URLs are
127.0.0.1, notlocalhost. Ports are published on IPv4 loopback, so a client that resolveslocalhostto::1first, or only, still reaches them.One renderer.
port_bindings(host_ips, host_port, container_port)returns one[ip:]host:containerspec per IP. Every publish site goes through it: the gateway and sidecarports:in the gateway compose, pgweb and db-mcp in the local Postgres fragment, the agent'sdocker run -pinSandboxProvider.create_container, a linked agent'sdocker runinA2AAgent._run_container, andrun_docker_container'sports.Threading.
create_docker_compose,DatabaseStateProvider.render_compose_containersandrender_sidecar_containerstakehost_ipsnext to the existinghost_port, defaulting to()._deploy_via_vmpassessandbox.host_ips, and so does the MultiEnv snapshot reload, which regenerates the compose and force-recreates the gateway, pgweb and db-mcp.Why
Every local deploy was reachable from the LAN: the gateway (MCP, tool toggles,
/step), the MCP servers, pgweb (unauthenticated SQL as a superuser), db-mcp (--access-mode=unrestricted) and the agent, which holds the injectedLITELLM_API_KEY. On Rancher Desktop, lima's forwarder listened on*:<port>.Loopback alone would break agent -> gateway on native Linux:
host.docker.internalis--add-host …:host-gatewaythere, the bridge gateway address, which cannot reach a port bound only to127.0.0.1. So on Linux the ports are also published on that address.Testing
Where this was tested. Every row, and every chaos run below, ran against this head,
33f6af4, and passed. The controls ran againstorigin/mainand against this PR's first commit. The details follow the table.127.0.0.1only, nothing on the LAN, VPN or VM-bridge addressesorigin/main, the first commit and this head; a network-namespace LAN neighbour; chaos127.0.0.1task_steps_test.py::test_task_steps_e2e[modal]test_task_steps_e2e[modal_vm]P:Ptest_task_steps_e2eP:PChaos runs, with results under Chaos below:
::1first;docker restartof the stack, and a Docker daemon restart;dockerCLI that hangs.Unit.
pytest tst/unit packages/agentenv-protocol/tests: 5643 passed, 13 skipped on macOS; 5648 passed, 5 skipped on Ubuntu 22.04. The same run withplatform.system()forced toLinuxand adockerthat fails on any call also passes, and makes nodocker network inspectcall. New and changed tests:("127.0.0.1",)and on("127.0.0.1", "172.17.0.1")publishes the gateway, pgweb, db-mcp and a sidecar once per IP and never bare;port_bindingscalls for the resolved host port, and a new guard fails on any compose port mapping written by hand, double-quoted, single-quoted or bare;docker runand a linked agent's carry-p 127.0.0.1:… -p 172.17.0.1:…;_host_ipsis loopback off Linux with no docker call. On Linux it adds the bridge gateway, read once with a timeout on each query. It stays loopback on Docker Desktop, with no bridge query, and when the daemon has no default bridge;18765:18765, and the existing remote-stylecreate_containertest still sees-p 57863:8000;_deploy_via_vmand the snapshot reload pass the sandbox'shost_ipsto the compose.Negative control. With
src/atorigin/main,compose_host_port_publish_test.pyfails 4 tests: both source guards (one finds noport_bindingssite, the other lists the four hand-written mappings) and the two rendered tests (unexpected keyword argument 'host_ips'), the snapshot-reload anddeploy()wiring tests fail withKeyError: 'host_ips', andlocal_sandbox_test.pyfails to collect (no attribute '_host_ips'). The four tests the second commit adds fail against the first, and the ones the third and fourth add fail against the commit before them.Integration (macOS, Rancher Desktop).
server_env_local_test.py,run_bundle_test.pyandtest_verify_sandbox_local.py: 10 passed. The server env's container comes up asdocker run -p 127.0.0.1:<host>:18765.multi_env_local_test.py(gateway, two MCP servers and local Postgres on compose; deploy, restore, load, close) andtask_steps_test.py::test_load_universe_on_single_mcp_server_env[default]: 2 passed.test_load_artifact_stages_files_into_env_and_agentfails on Apple Silicon at the agent'sdocker pull, before the changeddocker run: the fixture builds an amd64-only image and the pull findsno matching manifest for linux/arm64/v8. CI'sintegration-local-slowruns it on ubuntu.E2E on Rancher Desktop (macOS 26.5, dockerd 29.5). A bundle deploys a gateway env (slack MCP server, local Postgres, pgweb, db-mcp) and the echo agent, prompts it, and scores the reply with
agent_prompt_response_verifier, kept up while every published port is probed.origin/main)docker port(4 ports)0.0.0.0:P,[::]:P127.0.0.1:P127.0.0.1:P,172.17.0.1:Plsof)*:P127.0.0.1:P127.0.0.1:Phost.docker.internal172.17.0.1platform.system()toLinuxbefore import, against Rancher's Linux dockerd, wherehost-gatewayis the VM'sdocker0. With the bridge address left out (loopback only), agent -> gateway isConnection refused.E2E on native Linux (Ubuntu 22.04, Docker Engine 29.8.1, x86_64). The same bundle, run from
origin/main, the first commit and this head. Besides the host's own probes, a network namespace on a veth pair stands in for a machine on the host's LAN. Its packets arrive throughPREROUTINGlike a real neighbour's, and it routes everything through the host,172.17.0.0/16included.origin/main)docker portandss(4 ports)0.0.0.0:P,[::]:P127.0.0.1:P,172.17.0.1:P127.0.0.1:P172.17.0.1, from the neighbourhost.docker.internal(172.17.0.1)Connection refused"after" is this head. The first commit also published pgweb and db-mcp on the bridge address. A final run on this head, on native Linux and on Rancher Desktop for macOS, hands out
http://127.0.0.1:PURLs and passes the same checks.Docker Desktop 4.93.0 for Linux (Ubuntu 22.04 on an EC2 instance with nested virtualization, engine 29.8.1).
docker inforeportsOperatingSystem=Docker Desktop, which is what_host_ipsreads.172.17.0.1inside Docker Desktop's VM, but no host interface has it.docker run -p 127.0.0.1:P:80 -p 172.17.0.1:P:80exits 125 withports are not available: exposing port TCP 172.17.0.1:P -> 127.0.0.1:0: listen tcp4 172.17.0.1:P: bind: cannot assign requested address.--add-host host.docker.internal:host-gateway, as the Linux path does, reaches a port published only on127.0.0.1. There,host-gatewayresolves to Docker Desktop's own host address.docker compose upwith the error above. This head deploys everything on127.0.0.1: gateway, pgweb, db-mcp and agent. Agent -> gateway throughhost.docker.internalreturns 200, the verifier scores 1.0, and teardown leaves no listener.Remote providers.
task_steps_test.py::test_task_steps_e2e[modal_vm]and[modal]passed on this head, as they did on the first commit. That covers env deploy, artifact loads, agent deploy, prompt and every verifier, on Modal VMs and on Modal containers. So did the same test on a third VM-based provider plugin. The VM composes still render bare mappings ("18765:18765","18767:8000","8081:8081").Chaos (all rerun against this head,
33f6af4).eth0(Linux path). After: the deploy passes, the other process keeps answering on its address, and the gateway serves on loopback. Before, on the Linux path:failed to bind host port 0.0.0.0:P/tcp: address already in use. On native Linux, with another process on the host's LAN address, the result is the same both ways: after, the deploy passes and the other process keeps answering; before,address already in use.localhostresolving to::1first. Withgetaddrinforeordered, urllib, httpx sync and httpx async all connect.curlwith::1listed first connects through127.0.0.1. The same holds on native Linux, wherelocalhostresolves to127.0.0.1alone, with::1forced first.docker restartof the stack's own containers, not a daemon restart, which would restart other sessions' containers. They come back in 3 to 6 s with the same bindings, on both paths, and agent -> gateway still works.unless-stopped), come back with their bindings,127.0.0.1:Ponly. The stack as a whole doesn't come back, because the gateway, servicedb, MCP server and agent have restart policynooron-failure. This PR doesn't change that."bridge": "none"). An env-only deploy on the first commit fails withCalledProcessErrorfromdocker network inspect bridge. On this head it deploys on127.0.0.1alone._host_ips()raises and caches nothing. On this head the error carries docker's message:docker info --format {{.OperatingSystem}} failed: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?. Once the daemon is started again, it returns127.0.0.1and172.17.0.1. The second commit, by contrast, cached('127.0.0.1',)and kept returning it after the daemon came back.dockerCLI that hangs oninfoandnetwork inspect. The first commit hangs until it is killed (75 s here). This head raisesTimeoutExpiredafter 30 s.Not in this PR
MultiEnv._load_from_snapshotstill regenerates the compose withouthost_port, so on local the recreated services come back on the identity host ports. That is not what the sandbox'stunnel_urlsadvertise. This PR only makes those ports loopback too.get_sandboxrebuilds the sandbox without a port map. Itsdocker runnow binds loopback, but the path does not work on local today.172.17.0.1. Containers need that address to reach them, and Docker accepts traffic to it from any interface. pgweb and db-mcp are off it. Before this PR, that neighbour and anyone else on the network reached every port on every address. Closing it fully takes a host firewall rule.🤖 Generated with Claude Code