Skip to content

fix(sandbox): local deploys publish their ports on loopback, not every interface - #15

Merged
earakely-scale merged 8 commits into
mainfrom
edgararakelyan/local-ports-on-loopback
Oct 1, 2026
Merged

earakely-scale merged 8 commits into
mainfrom
edgararakelyan/local-ports-on-loopback

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

Local deploys now publish their ports on 127.0.0.1 only, plus the Docker bridge gateway on Linux, instead of on every interface. Remote providers are unchanged.

  • The seam. Sandbox.host_ips is 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_ips returns 127.0.0.1, and on Linux also the default bridge's gateway, read once per process from docker network inspect bridge. Two Linux setups get loopback alone:

    • Docker Desktop, recognised by docker info reporting Docker 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).
    • A daemon with no default bridge, read from 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, not localhost. Ports are published on IPv4 loopback, so a client that resolves localhost to ::1 first, or only, still reaches them.

  • One renderer. port_bindings(host_ips, host_port, container_port) returns one [ip:]host:container spec per IP. Every publish site goes through it: the gateway and sidecar ports: in the gateway compose, pgweb and db-mcp in the local Postgres fragment, the agent's docker run -p in SandboxProvider.create_container, a linked agent's docker run in A2AAgent._run_container, and run_docker_container's ports.

  • Threading. create_docker_compose, DatabaseStateProvider.render_compose_containers and render_sidecar_containers take host_ips next to the existing host_port, defaulting to (). _deploy_via_vm passes sandbox.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 injected LITELLM_API_KEY. On Rancher Desktop, lima's forwarder listened on *:<port>.

Loopback alone would break agent -> gateway on native Linux: host.docker.internal is --add-host …:host-gateway there, the bridge gateway address, which cannot reach a port bound only to 127.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 against origin/main and against this PR's first commit. The details follow the table.

Platform What ran Result
macOS, Rancher Desktop 1.24 (dockerd 29.5) e2e bundle kept up and probed on every interface; chaos passed; every port on 127.0.0.1 only, nothing on the LAN, VPN or VM-bridge addresses
Native Linux, Docker Engine 29.8.1 (Ubuntu 22.04, x86_64) e2e bundle from origin/main, the first commit and this head; a network-namespace LAN neighbour; chaos passed; the gateway and agent also on the bridge address, which containers need; pgweb and db-mcp on loopback only
Docker Desktop 4.93.0 for Linux (headless, nested virtualization) three targeted checks; e2e bundle from the first commit and this head passed; the first commit fails with docker/desktop-feedback#485's bind error, this head runs on 127.0.0.1
Modal containers task_steps_test.py::test_task_steps_e2e[modal] passed; remote renderers unchanged
Modal VMs test_task_steps_e2e[modal_vm] passed; the VM compose still publishes bare P:P
A VM-based sandbox provider plugin test_task_steps_e2e passed; bare P:P
CI (ubuntu) unit, integration-local, integration-local-slow, clean-install, plugin-api green
Not run Docker Desktop on WSL 2, rootless Docker the WSL 2 case uses the same port proxy as Docker Desktop for Linux; E2B is covered by unit tests only

Chaos runs, with results under Chaos below:

  • a port already taken on another interface;
  • two concurrent deploys;
  • clients that try ::1 first;
  • a docker restart of the stack, and a Docker daemon restart;
  • the daemon going down and coming back;
  • a daemon with no default bridge;
  • a docker CLI 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 with platform.system() forced to Linux and a docker that fails on any call also passes, and makes no docker network inspect call. New and changed tests:

  • the gateway compose on ("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;
  • the source guard now scans port_bindings calls for the resolved host port, and a new guard fails on any compose port mapping written by hand, double-quoted, single-quoted or bare;
  • the agent's docker run and a linked agent's carry -p 127.0.0.1:… -p 172.17.0.1:…;
  • _host_ips is 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;
  • Modal VM, Modal and E2B still render a bare 18765:18765, and the existing remote-style create_container test still sees -p 57863:8000;
  • _deploy_via_vm and the snapshot reload pass the sandbox's host_ips to the compose.

Negative control. With src/ at origin/main, compose_host_port_publish_test.py fails 4 tests: both source guards (one finds no port_bindings site, the other lists the four hand-written mappings) and the two rendered tests (unexpected keyword argument 'host_ips'), the snapshot-reload and deploy() wiring tests fail with KeyError: 'host_ips', and local_sandbox_test.py fails 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).

  • Fast. server_env_local_test.py, run_bundle_test.py and test_verify_sandbox_local.py: 10 passed. The server env's container comes up as docker run -p 127.0.0.1:<host>:18765.
  • Slow. multi_env_local_test.py (gateway, two MCP servers and local Postgres on compose; deploy, restore, load, close) and task_steps_test.py::test_load_universe_on_single_mcp_server_env[default]: 2 passed.
  • Fails here, unrelated. test_load_artifact_stages_files_into_env_and_agent fails on Apple Silicon at the agent's docker pull, before the changed docker run: the fixture builds an amd64-only image and the pull finds no matching manifest for linux/arm64/v8. CI's integration-local-slow runs 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.

before (origin/main) after after, native-Linux path
docker port (4 ports) 0.0.0.0:P, [::]:P 127.0.0.1:P 127.0.0.1:P, 172.17.0.1:P
Mac listener (lsof) *:P 127.0.0.1:P 127.0.0.1:P
LAN, VPN and VM-bridge IPs of the Mac connected not listening not listening
localhost (gateway card, pgweb, agent card) 200 200 200
agent -> gateway via host.docker.internal 200 200 200, through 172.17.0.1
verifier 1.0 1.0 1.0
after teardown no listener no listener no listener
  • Native-Linux path. Forced by patching platform.system() to Linux before import, against Rancher's Linux dockerd, where host-gateway is the VM's docker0. With the bridge address left out (loopback only), agent -> gateway is Connection refused.
  • "Not listening" on the Mac is a timeout rather than a reset: the host firewall runs in stealth mode, and port 1 and the loopback-only registry port time out the same way.

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 through PREROUTING like a real neighbour's, and it routes everything through the host, 172.17.0.0/16 included.

before (origin/main) after after, loopback only
docker port and ss (4 ports) 0.0.0.0:P, [::]:P 127.0.0.1:P, 172.17.0.1:P 127.0.0.1:P
host's LAN address, from the host and from the neighbour connected refused refused
bridge address 172.17.0.1, from the neighbour connected gateway and agent connect; pgweb and db-mcp refused (this head) refused
localhost (gateway card, pgweb, agent card) 200 200 200
agent -> gateway via host.docker.internal (172.17.0.1) 200 200 Connection refused
verifier 1.0 1.0 1.0
after teardown no listener no listener no listener

"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:P URLs 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 info reports OperatingSystem=Docker Desktop, which is what _host_ips reads.

  • Publishing on the bridge address fails, as in docker/desktop-feedback#485. The bridge gateway is 172.17.0.1 inside 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:80 exits 125 with ports 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.
  • Loopback alone is enough. A container started with --add-host host.docker.internal:host-gateway, as the Linux path does, reaches a port published only on 127.0.0.1. There, host-gateway resolves to Docker Desktop's own host address.
  • The e2e bundle: the first commit's deploy fails at docker compose up with the error above. This head deploys everything on 127.0.0.1: gateway, pgweb, db-mcp and agent. Agent -> gateway through host.docker.internal returns 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).

  • Port already bound on another interface. Another process held the gateway's host port on the LAN IP (Mac), or on the VM's 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.
  • localhost resolving to ::1 first. With getaddrinfo reordered, urllib, httpx sync and httpx async all connect. curl with ::1 listed first connects through 127.0.0.1. The same holds on native Linux, where localhost resolves to 127.0.0.1 alone, with ::1 forced first.
  • Two concurrent deploys, on both platforms. Both pass, on disjoint host ports, each on loopback only, and each agent reaches its own gateway.
  • Restart with the stack up. docker restart of 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.
  • Docker daemon restart with the stack up (native Linux). The containers that restart, db-mcp and pgweb (unless-stopped), come back with their bindings, 127.0.0.1:P only. The stack as a whole doesn't come back, because the gateway, servicedb, MCP server and agent have restart policy no or on-failure. This PR doesn't change that.
  • No default bridge (native Linux, "bridge": "none"). An env-only deploy on the first commit fails with CalledProcessError from docker network inspect bridge. On this head it deploys on 127.0.0.1 alone.
  • Docker daemon down, then back (native Linux, one process). With the daemon stopped, _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 returns 127.0.0.1 and 172.17.0.1. The second commit, by contrast, cached ('127.0.0.1',) and kept returning it after the daemon came back.
  • A docker CLI that hangs on info and network inspect. The first commit hangs until it is killed (75 s here). This head raises TimeoutExpired after 30 s.
  • Teardown. Every successful run left no listener, container or work folder.

Not in this PR

  • Snapshot reload host ports (from reading the code). MultiEnv._load_from_snapshot still regenerates the compose without host_port, so on local the recreated services come back on the identity host ports. That is not what the sandbox's tunnel_urls advertise. This PR only makes those ports loopback too.
  • Linked agent on local (from reading the code). Deploying one onto a local VM sandbox still has no tunnel URL for its port, because get_sandbox rebuilds the sandbox without a port map. Its docker run now binds loopback, but the path does not work on local today.
  • Docker Desktop on WSL 2 was not run, only Docker Desktop for Linux. It uses the same host-side port proxy. Rootless Docker was not tried.
  • On native Linux, a neighbour that routes the bridge subnet through the host still reaches the gateway and agent ports on 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.
  • A failed local deploy can leave its compose stack and work folder behind, for example on a port conflict. This happens before and after this PR.

🤖 Generated with Claude Code

…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>
Comment thread src/agent_env/providers/sandbox_providers/local_sandbox.py Outdated
Comment thread src/agent_env/providers/sandbox_providers/local_sandbox.py Outdated
Comment thread tst/unit/providers/compose_host_port_publish_test.py Outdated
… 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>
Comment thread src/agent_env/providers/sandbox_providers/local_sandbox.py Outdated
Comment thread tst/unit/providers/compose_host_port_publish_test.py
earakely-scale and others added 3 commits September 30, 2026 10:38
…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>
@earakely-scale
earakely-scale requested a review from a team as a code owner September 30, 2026 21:31
earakely-scale and others added 3 commits September 30, 2026 11:39
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
" - 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)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work? It is no longer doing IP:PORT right? Or am I misreading the python

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@earakely-scale
earakely-scale merged commit 06c7d18 into main Oct 1, 2026
18 checks passed
@earakely-scale
earakely-scale deleted the edgararakelyan/local-ports-on-loopback branch October 1, 2026 00:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants