Skip to content
Merged
5 changes: 3 additions & 2 deletions src/agent_env/a2a_agent/a2a_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from agent_env.config import get_config
from agent_env.entity_refs import EntityRef
from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError
from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, NetworkPolicyUnsupportedError, port_bindings
from agent_env.artifact.artifacts.docker_image import DockerImageArtifact
from agent_env.a2a_agent.object_transfer import (
TRANSFER_TIMEOUT_SECONDS,
Expand Down Expand Up @@ -533,12 +533,13 @@ async def _run_container(self, image_name: str, a2a_port: int, merged_env: dict[
escaped_value = value.replace("'", "'\\''")
env_flags.append(f"-e {key}='{escaped_value}'")
env_str = " \\\n ".join(env_flags)
publish = " ".join(f"-p {spec}" for spec in port_bindings(self._sandbox.host_ips, a2a_port, a2a_port))

run_script = f"""#!/bin/bash
set -e
{setup_script}docker run -d \\
--name {self._sandbox.container_name} \\
-p {a2a_port}:{a2a_port} \\
{publish} \\
{network_flag}{env_str} \\
{image_name} > /dev/null
sleep 2
Expand Down
1 change: 1 addition & 0 deletions src/agent_env/env/envs/multi_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ async def _load_from_snapshot(self, snapshot) -> None:
gateway_mode=self._gateway_mode,
state_provider=gw._state_provider, # local-only, set above
state_instance=gw._state_instance,
host_ips=self._sandbox.host_ips,
mcp_server_name=self._mcp_server_name or self.name,
)
compose_content = compose_content.replace(
Expand Down
10 changes: 6 additions & 4 deletions src/agent_env/providers/env_providers/env_gateway_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
)
from agent_env.providers.env_providers.env_provider import _SandboxEnvironmentProvider, _size_kwargs, _tool_names
from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider
from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox
from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox, port_bindings
from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER, SandboxProvider

if TYPE_CHECKING:
Expand Down Expand Up @@ -210,6 +210,7 @@ def create_docker_compose(
state_provider: "DatabaseStateProvider",
state_instance: "EnvStateInstance",
host_port: Optional[Callable[[int], int]] = None,
host_ips: tuple[str, ...] = (),
mcp_server_name: str | None = None,
) -> str:
"""Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine.
Expand Down Expand Up @@ -316,7 +317,7 @@ def create_docker_compose(

lines.extend(
state_provider.render_compose_containers(
environment_names, instance=state_instance, host_port=host_port
environment_names, instance=state_instance, host_port=host_port, host_ips=host_ips
)
)
store_dep_lines = state_provider.docker_service_dependency()
Expand Down Expand Up @@ -417,7 +418,7 @@ def create_docker_compose(
if expose_gateway_port:
exposed_gateway_ports.extend([
" ports:",
f' - "{publish(gateway_port)}:{gateway_port}"',
*(f' - "{spec}"' for spec in port_bindings(host_ips, publish(gateway_port), gateway_port)),
])
lines.extend([
" environment:",
Expand Down Expand Up @@ -486,7 +487,7 @@ def create_docker_compose(
lines.append(f" - {key}={value}")
lines.extend([
" ports:",
f' - "{publish(sc.host_port)}:{sc.container_port}"',
*(f' - "{spec}"' for spec in port_bindings(host_ips, publish(sc.host_port), sc.container_port)),
f" restart: {sc.restart}",
" networks:",
" - env-network",
Expand Down Expand Up @@ -706,6 +707,7 @@ async def _stand_up(_spec):
state_provider=self._state_provider,
state_instance=state_instance,
host_port=sandbox.host_port,
host_ips=sandbox.host_ips,
mcp_server_name=mcp_server_name,
)
logger.info(f"Generated docker-compose.yml:\n{_redact_compose_secrets(compose_content)}")
Expand Down
6 changes: 4 additions & 2 deletions src/agent_env/providers/env_state/env_state_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ def render_sidecar_containers(
environment_names: list[str],
*,
host_port: Optional[Callable[[int], int]] = None,
host_ips: tuple[str, ...] = (),
) -> list[str]:
"""Extra co-deployed compose services beyond the readiness one (e.g. pgweb / db-mcp browse
UIs). Optional: a backend may have none, in which case this returns an empty list."""
Expand All @@ -212,16 +213,17 @@ def render_compose_containers(
*,
instance: EnvStateInstance | None = None,
host_port: Optional[Callable[[int], int]] = None,
host_ips: tuple[str, ...] = (),
) -> list[str]:
"""All co-deployed compose services for this backend — the mandatory readiness service plus
any sidecars — spliced into the gateway's docker-compose doc.

``host_port`` maps a container port to the host port to publish it on; defaults
to identity.
to identity. ``host_ips`` are the host IPs to publish on; none means every interface.
"""
return [
*self.render_healthcheck_service(environment_names, instance=instance),
*self.render_sidecar_containers(environment_names, host_port=host_port),
*self.render_sidecar_containers(environment_names, host_port=host_port, host_ips=host_ips),
]

def docker_service_dependency(self) -> list[str]:
Expand Down
8 changes: 6 additions & 2 deletions src/agent_env/providers/env_state/local_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
PGWEB_SERVICE_NAME,
SERVICE_DB_PORT,
)
from agent_env.providers.sandbox_providers.sandbox import port_bindings
from agent_env.providers.env_state.env_state_provider import (
DatabaseStateProvider,
DEFAULT_STATE_TTL_SECONDS,
Expand Down Expand Up @@ -258,9 +259,12 @@ def render_sidecar_containers(
environment_names: list[str],
*,
host_port: Optional[Callable[[int], int]] = None,
host_ips: tuple[str, ...] = (),
) -> list[str]:
"""The pgweb + db-mcp direct-SQL browse UIs (VM/compose path), each gated on the
servicedb's health. Beyond the mandatory readiness service."""
# Opened from the host only, so kept off the bridge address containers reach the host through.
host_ips = host_ips[:1]
publish = host_port or (lambda port: port)
cfg = self.service_db_config
lines: list[str] = []
Expand All @@ -279,7 +283,7 @@ def render_sidecar_containers(
# session and 500'd the changelog export on Save & Next).
" - 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.

" networks:",
" - env-network",
"",
Expand All @@ -296,7 +300,7 @@ def render_sidecar_containers(
f" - DATABASE_URI={self._pg_url(DATABASE_SERVICE_NAME, scheme='postgresql')}",
" command: --transport=streamable-http --streamable-http-host=0.0.0.0 --access-mode=unrestricted",
" ports:",
f' - "{publish(DB_MCP_PORT)}:{DB_MCP_CONTAINER_PORT}"',
*(f' - "{spec}"' for spec in port_bindings(host_ips, publish(DB_MCP_PORT), DB_MCP_CONTAINER_PORT)),
" networks:",
" - env-network",
"",
Expand Down
32 changes: 31 additions & 1 deletion src/agent_env/providers/sandbox_providers/local_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import contextlib
import functools
import glob
import logging
import os
Expand Down Expand Up @@ -113,8 +114,9 @@ def __init__(self, exposed_ports: list[int] | None = None, work_dir: Path | None
port: port for port in (exposed_ports or [])
}
# Keyed by container port: callers index this with the well-known constants.
# 127.0.0.1, not localhost: ports are published on IPv4 loopback, and a client may try ::1 first.
self.tunnel_urls = {
container: f"http://localhost:{host}" for container, host in self._port_map.items()
container: f"http://127.0.0.1:{host}" for container, host in self._port_map.items()
}
self.vnc_url = None
self.mode = SANDBOX_MODE_VM
Expand All @@ -126,6 +128,11 @@ def host_port(self, port: int) -> int:
"""The allocated host port for a published container port (identity if unmapped)."""
return self._port_map.get(port, port)

@property
def host_ips(self) -> tuple[str, ...]:
"""Loopback, so a local deploy is not reachable from the network (see ``_host_ips``)."""
return _host_ips()

@classmethod
def find_work_dir(cls, sandbox_id: str) -> Path | None:
"""Find the work directory for a previously created local sandbox.
Expand Down Expand Up @@ -406,3 +413,26 @@ def get_external_url(cls, url: str) -> str:
inside a container: ``host.docker.internal``. Docker Desktop and Rancher Desktop (macOS/Windows)
resolve this themselves; on Linux ``EXTRA_CONTAINER_RUN_ARGS`` maps it via ``--add-host …:host-gateway``."""
return url.replace("localhost", "host.docker.internal").replace("127.0.0.1", "host.docker.internal")


@functools.cache
def _host_ips() -> tuple[str, ...]:
"""Loopback, plus on Linux the bridge gateway ``host-gateway`` resolves to, where containers reach the host."""
# Docker Desktop publishes through a host-side proxy, which can't bind the bridge address inside its VM.
if platform.system() != "Linux" or _docker("info", "--format", "{{.OperatingSystem}}") == "Docker Desktop":
return ("127.0.0.1",)
# Listing, unlike inspecting, answers a missing network with nothing rather than an error.
if not _docker("network", "ls", "--quiet", "--filter", "name=^bridge$"):
return ("127.0.0.1",)
return ("127.0.0.1", _docker("network", "inspect", "bridge", "--format", "{{(index .IPAM.Config 0).Gateway}}"))


def _docker(*args: str) -> str:
"""A docker CLI query's output. A failure raises, so the cache above never keeps it."""
run = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=_DOCKER_QUERY_SECONDS)
if run.returncode:
raise RuntimeError(f"docker {' '.join(args)} failed: {run.stderr.strip()}")
return run.stdout.strip()


_DOCKER_QUERY_SECONDS = 30
7 changes: 7 additions & 0 deletions src/agent_env/providers/sandbox_providers/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ class Sandbox(ABC):
vnc_url: str | None
# Effective, as applied by the backend; None when it cannot tell us.
network_policy: NetworkPolicy | None = None
# Host IPs published ports bind to; empty binds every interface.
host_ips: tuple[str, ...] = ()

_VM_READY_TIMEOUT = 1200 # wait_for_vm wall-clock budget (s)
_VM_READY_POLL_INTERVAL = 30 # sparse polling (s)
Expand Down Expand Up @@ -385,3 +387,8 @@ async def write_file_from_text(self, content: str, destination_path: str) -> Non
await self._copy_into_container(vm_path, destination_path)
finally:
await self._remove_vm_temp_file(vm_path, f"{vm_path}.b64")


def port_bindings(host_ips: Iterable[str], host_port: int, container_port: int) -> list[str]:
"""Docker publish specs for one port: one per host IP, or a bare one (every interface) when there are none."""
return [f"{ip}:{host_port}:{container_port}" for ip in host_ips] or [f"{host_port}:{container_port}"]
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
NetworkPolicyUnsupportedError,
Sandbox,
VmSandbox,
port_bindings,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -113,9 +114,9 @@ async def create_container(
f"-e {shlex.quote(k)}={shlex.quote(v)}" for k, v in env.items()
)
extra_args = f"{self.EXTRA_CONTAINER_RUN_ARGS} " if self.EXTRA_CONTAINER_RUN_ARGS else ""
host_side = sandbox.host_port(port)
publish = " ".join(f"-p {spec}" for spec in port_bindings(sandbox.host_ips, sandbox.host_port(port), port))
await sandbox.exec_script(
f"docker run -d --name {shlex.quote(sandbox.container_name)} -p {host_side}:{port} {extra_args}\\\n "
f"docker run -d --name {shlex.quote(sandbox.container_name)} {publish} {extra_args}\\\n "
f"{env_flags} \\\n {shlex.quote(image_name)} > /dev/null"
)
sandbox.mode = SANDBOX_MODE_CONTAINER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from agent_env.task_step.context import TaskStepContext
from agent_env.entity_refs import EntityRef
from agent_env.providers.sandbox_providers.sandbox import port_bindings
from agent_env.task_step.task_step import TaskStep, TaskStepDependency
from agent_env.utils.paths import validate_relative_filename

Expand Down Expand Up @@ -206,7 +207,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext:
if self.network:
network_flag = f"--network {await self._ensure_network(sandbox, self.network)} "

port_flags = " ".join(f"-p {p}:{p}" for p in (self.ports or []))
port_flags = " ".join(f"-p {spec}" for p in (self.ports or []) for spec in port_bindings(sandbox.host_ips, p, p))
env_flag_parts = []
if self.env_vars:
for k, v in self.env_vars.items():
Expand Down
2 changes: 1 addition & 1 deletion tst/integration/env/envs/server_env_local_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async def test_a_server_env_deploys_restores_loads_and_tears_down_on_local_conta
container = f"agent-{deployed.sandbox_id}"
local_stack.append(container)
assert type(deployed) is DeployedSandboxEnv and deployed.sandbox_type == "local"
assert deployed.environment_card["name"] == "items" and deployed.mcp_url.startswith("http://localhost:")
assert deployed.environment_card["name"] == "items" and deployed.mcp_url.startswith("http://127.0.0.1:")

restored = await Env.from_instance_id(deployed.instance_id)
await restored.load_environment_artifact(_items_artifact(uid))
Expand Down
2 changes: 1 addition & 1 deletion tst/integration/store/local_task_lifecycle_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ async def test_task_lifecycle_all_local(all_local):
# is not identity — take the URL the deploy reported. Keyed by CONTAINER port
# (deploy_sandbox stringifies the keys), which is what the caller asked for.
served_url = deployed.tunnel_urls[str(app_port)]
assert re.fullmatch(r"http://localhost:\d+", served_url), served_url
assert re.fullmatch(r"http://127\.0\.0\.1:\d+", served_url), served_url

# the pulled image is actually running and serving on the published port
assert _wait_ready(f"{served_url}/", (200,)), \
Expand Down
5 changes: 3 additions & 2 deletions tst/unit/env/envs/test_multi_env_snapshot_reload_name.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""The snapshot reload regenerates the compose; it must reuse the name the running gateway already presents."""
"""The snapshot reload regenerates the compose; it must reuse the name and host IPs the running gateway already has."""

from __future__ import annotations

Expand All @@ -20,7 +20,7 @@ class _Stop(Exception):
async def test_snapshot_reload_keeps_the_env_name(declared, deployed_as, expected):
env = MultiEnv(id="crm-suite", version=1, mcp_server_envs=[], name=declared)
env._mcp_server_name = deployed_as
env._sandbox = MagicMock(mode="vm", load_docker_images=AsyncMock())
env._sandbox = MagicMock(mode="vm", load_docker_images=AsyncMock(), host_ips=("127.0.0.1",))
db_image = MagicMock(spec=DockerImageArtifact, image_name="snap:1")
snapshot = MagicMock(db_image_artifact_id="snap", db_image_artifact_version=1)
gateway_env, service_db = MagicMock(), MagicMock()
Expand All @@ -34,3 +34,4 @@ async def test_snapshot_reload_keeps_the_env_name(declared, deployed_as, expecte
await env._load_from_snapshot(snapshot)

assert compose.call_args.kwargs["mcp_server_name"] == expected
assert compose.call_args.kwargs["host_ips"] == ("127.0.0.1",)
Loading
Loading