diff --git a/.agentenv/config.example.toml b/.agentenv/config.example.toml index a820b98c..30903567 100644 --- a/.agentenv/config.example.toml +++ b/.agentenv/config.example.toml @@ -17,6 +17,11 @@ agent_default = "local" # agents too [agents] default_a2a_agent_id = "a2a-default" # what a deploy_agent step that names no agent deploys +# [model] +# base_url = "http://localhost:4000" # a model proxy on this machine; local containers reach it as host.docker.internal +# # on Linux, bind it to the Docker bridge address and use that: http://172.17.0.1:4000 +# api_key = "env:MY_PROXY_KEY" + [runner] impl = "agent_env.runner.local_runner:LocalRunner" diff --git a/AGENTS.md b/AGENTS.md index 9135ed2b..233c1f62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,12 @@ default stores are local. sandboxes for envs and agents. MongoDB, S3, Cloud Storage, ECR, AWS Secrets Manager, Google Cloud Secret Manager, Modal and E2B exist as implementations and are selected by config. - The model endpoint is unset until `[model] base_url` / `api_key` (or `LITELLM_BASE_URL` / - `LITELLM_API_KEY`) is configured. + `LITELLM_API_KEY`) is configured. A loopback URL handed into a `local` container (the model + endpoint, an agent, peer or gateway on this machine) becomes `host.docker.internal`. On Linux that + name is the Docker bridge address (`docker0`, usually `172.17.0.1`), not loopback, so a service on + this machine that local containers call, a model proxy say, has to listen there: bind it to the + bridge address and use that address in its URL. Binding `0.0.0.0` also works, but exposes the + service to the network. - `[agents] default_a2a_agent_id`: the agent a `deploy_agent` step without an id deploys; built-in `a2a-default`, overridable by `configure(default_a2a_agent_id=...)`. - Secrets never live in the file: use `secret:KEY` or `env:NAME` references, resolved through diff --git a/src/agent_env/a2a_agent/a2a_agent.py b/src/agent_env/a2a_agent/a2a_agent.py index 9621f340..a5a6a862 100644 --- a/src/agent_env/a2a_agent/a2a_agent.py +++ b/src/agent_env/a2a_agent/a2a_agent.py @@ -413,18 +413,22 @@ async def deploy( disk_size_gb = float(self.metadata.get("min_disk_size_gb") or 10) config = get_config() + provider = None if sandbox is not None else ( + build_sandbox_provider(sandbox_type) if sandbox_type else get_agent_sandbox_provider() + ) resolved_env = dict(env_vars) if env_vars else {} if "LITELLM_API_KEY" not in resolved_env and "LITELLM_API_KEY" not in self.default_env_vars: resolved_env["LITELLM_API_KEY"] = config.get_litellm_api_key() if "LITELLM_BASE_URL" not in resolved_env and "LITELLM_BASE_URL" not in self.default_env_vars: resolved_env["LITELLM_BASE_URL"] = config.get_litellm_base_url() + if "LITELLM_BASE_URL" in resolved_env and isinstance(provider or sandbox, (LocalSandboxProvider, LocalSandbox)): + resolved_env["LITELLM_BASE_URL"] = LocalSandboxProvider.get_external_url(resolved_env["LITELLM_BASE_URL"]) merged_env = await asyncio.to_thread(self._build_merged_env, resolved_env, a2a_port) image_name = self.docker_image_artifact.image_name try: - if sandbox is None: - provider = build_sandbox_provider(sandbox_type) if sandbox_type else get_agent_sandbox_provider() + if provider is not None: logger.info(f"Provisioning sandbox for A2A agent '{self.id}' via {type(provider).__name__}...") self._sandbox = await provider.create_sandbox( image_name=image_name, port=a2a_port, env=merged_env, @@ -504,9 +508,7 @@ async def _run_container(self, image_name: str, a2a_port: int, merged_env: dict[ trust_dir = await asyncio.to_thread(local_grant_trust) if isinstance(self._sandbox, LocalSandbox) else None agent_env = dict(merged_env) if trust_dir is None else {**LOCAL_TRUST_ENV, **merged_env} setup_script = "" - network_flag = "" if trust_dir is None or not LocalSandboxProvider.EXTRA_CONTAINER_RUN_ARGS else ( - f"{LocalSandboxProvider.EXTRA_CONTAINER_RUN_ARGS} \\\n " - ) + network_flag = "".join(f"--add-host {entry} \\\n " for entry in self._sandbox.extra_hosts) if enable_docker: logger.info("enable_docker: starting rootless Docker-in-Docker sidecar for the agent (no host socket)") setup_script = self._dind_setup_script() diff --git a/src/agent_env/env/envs/multi_env.py b/src/agent_env/env/envs/multi_env.py index abba5865..2da0ca9f 100644 --- a/src/agent_env/env/envs/multi_env.py +++ b/src/agent_env/env/envs/multi_env.py @@ -610,6 +610,7 @@ async def _load_from_snapshot(self, snapshot) -> None: state_provider=gw._state_provider, # local-only, set above state_instance=gw._state_instance, host_ips=self._sandbox.host_ips, + extra_hosts=self._sandbox.extra_hosts, mcp_server_name=self._mcp_server_name or self.name, ) compose_content = compose_content.replace( diff --git a/src/agent_env/providers/env_providers/env_gateway_provider.py b/src/agent_env/providers/env_providers/env_gateway_provider.py index 291d4b11..6d8e78a6 100644 --- a/src/agent_env/providers/env_providers/env_gateway_provider.py +++ b/src/agent_env/providers/env_providers/env_gateway_provider.py @@ -211,6 +211,7 @@ def create_docker_compose( state_instance: "EnvStateInstance", host_port: Optional[Callable[[int], int]] = None, host_ips: tuple[str, ...] = (), + extra_hosts: tuple[str, ...] = (), mcp_server_name: str | None = None, ) -> str: """Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine. @@ -420,10 +421,13 @@ def create_docker_compose( " ports:", *(f' - "{spec}"' for spec in port_bindings(host_ips, publish(gateway_port), gateway_port)), ]) + # The gateway calls out to agents, as a trigger's executor, through the host. + gateway_extra_hosts = [" extra_hosts:", *(f' - "{entry}"' for entry in extra_hosts)] if extra_hosts else [] lines.extend([ " environment:", *gateway_env, *exposed_gateway_ports, + *gateway_extra_hosts, " restart: on-failure", " healthcheck:", f' test: ["CMD-SHELL", "python3 -c \\"import socket; s=socket.create_connection((\'localhost\',{gateway_port}),2); s.close()\\""]', @@ -704,6 +708,7 @@ async def _stand_up(_spec): state_instance=state_instance, host_port=sandbox.host_port, host_ips=sandbox.host_ips, + extra_hosts=sandbox.extra_hosts, mcp_server_name=mcp_server_name, ) logger.info(f"Generated docker-compose.yml:\n{_redact_compose_secrets(compose_content)}") diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index 44960796..256fd084 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -20,8 +20,10 @@ import socket import subprocess import tempfile +from ipaddress import ip_address from pathlib import Path from typing import TYPE_CHECKING, Any, Optional +from urllib.parse import urlsplit, urlunsplit from uuid import uuid4 from agent_env import config @@ -42,6 +44,11 @@ logger = logging.getLogger(__name__) +# The name a container on this machine reaches it by. Only Linux lacks it natively; on Rancher Desktop an explicit +# mapping would point it at the VM. +_HOST_ALIAS = "host.docker.internal" +_EXTRA_HOSTS = (f"{_HOST_ALIAS}:host-gateway",) if platform.system() == "Linux" else () + _APP_PATH_PATTERN = re.compile(r"(? Path | None: class LocalSandboxProvider(SandboxProvider): """SandboxProvider that runs VM-style gateway deployments on local Docker.""" - # Only Linux lacks a native host.docker.internal; on Rancher Desktop this mapping would point it at the VM. - EXTRA_CONTAINER_RUN_ARGS = "--add-host host.docker.internal:host-gateway" if platform.system() == "Linux" else "" + EXTRA_CONTAINER_RUN_ARGS = " ".join(f"--add-host {entry}" for entry in _EXTRA_HOSTS) async def create_vm( self, @@ -492,10 +499,34 @@ def shares_network_with(cls, sandbox_type: Optional[str]) -> bool: @classmethod def get_external_url(cls, url: str) -> str: - """Rewrite a ``localhost`` URL (a published host port) to the form a workload reaches from - 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") + """``url`` as a container on this machine reaches it: a loopback host (a published host port, or a + service on this machine) becomes ``host.docker.internal``, and anything else is left alone. Docker + Desktop and Rancher Desktop resolve that name themselves; on Linux ``extra_hosts`` maps it to the + bridge gateway, so a service there must listen on more than loopback.""" + parts = urlsplit(url) + if not _is_loopback(parts.hostname): + return url + userinfo, at, _ = parts.netloc.rpartition("@") + port = f":{parts.port}" if parts.port is not None else "" + return urlunsplit(parts._replace(netloc=f"{userinfo}{at}{_HOST_ALIAS}{port}")) + + +def host_url_for(url: str, sandbox_type: Optional[str]) -> str: + """``url``, which this machine reaches, as a container on ``sandbox_type`` reaches it: only a local + sandbox's containers run on this machine, and they reach its loopback by another name.""" + return LocalSandboxProvider.get_external_url(url) if sandbox_type == LocalSandbox.type else url + + +def _is_loopback(host: Optional[str]) -> bool: + """Whether ``host`` names this machine from itself: ``localhost``, a loopback address, or the unspecified + address a server listening everywhere prints.""" + if host == "localhost": + return True + try: + address = ip_address(host or "") + except ValueError: + return False + return address.is_loopback or address.is_unspecified @functools.cache diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index 86985046..60d0873c 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -146,6 +146,8 @@ class Sandbox(ABC): network_policy: NetworkPolicy | None = None # Host IPs published ports bind to; empty binds every interface. host_ips: tuple[str, ...] = () + # ``name:address`` entries the containers started on this sandbox add to their hosts file. + extra_hosts: tuple[str, ...] = () _VM_READY_TIMEOUT = 1200 # wait_for_vm wall-clock budget (s) _VM_READY_POLL_INTERVAL = 30 # sparse polling (s) diff --git a/src/agent_env/task_step/context.py b/src/agent_env/task_step/context.py index ed69bf42..79c2336c 100644 --- a/src/agent_env/task_step/context.py +++ b/src/agent_env/task_step/context.py @@ -7,6 +7,8 @@ from typing import Any from agent_env.env.env import DeployedEnv +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, host_url_for +from agent_env.providers.sandbox_providers.sandbox_provider import reachable_url from agent_env.utils.deprecation import warn_deprecated _REDACTED_KEYS = { @@ -81,6 +83,17 @@ def from_dict(cls, data: dict) -> DeployedAgent: on_host=bool(data.get("on_host", False)), ) + def url_for(self, sandbox_type: str | None, *, on_host: bool = False) -> str: + """This agent's A2A URL as an agent on ``sandbox_type`` reaches it, in a container there or, ``on_host``, on + the sandbox's host. A local sandbox's host is this machine, which reaches the URL as it is. An agent outside + our sandboxes, a human say, is at a URL this machine reaches.""" + url = self.a2a_url or self.api_url + if on_host and sandbox_type == LocalSandbox.type: + return url + if not self.sandbox_id: + return host_url_for(url, sandbox_type) + return reachable_url(url, from_sandbox_type=self.sandbox_type, to_sandbox_type=sandbox_type) + @dataclass class DeployedSandbox: diff --git a/src/agent_env/task_step/task_steps/install_agent.py b/src/agent_env/task_step/task_steps/install_agent.py index 659f6e31..223f881b 100644 --- a/src/agent_env/task_step/task_steps/install_agent.py +++ b/src/agent_env/task_step/task_steps/install_agent.py @@ -21,6 +21,7 @@ import httpx +from agent_env.providers.sandbox_providers.local_sandbox import host_url_for from agent_env.task_step.context import DeployedAgent, TaskStepContext from agent_env.entity_refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -43,6 +44,7 @@ def _build_param_resolvers( agent_name: str, workspace_dir: Optional[str] = None, litellm_api_key_override: Optional[str] = None, + sandbox_type: Optional[str] = None, ) -> dict[str, Callable[[], str]]: """Per-install registry: required_params name -> zero-arg resolver returning the raw value. @@ -81,7 +83,7 @@ def _resolve_container() -> str: "workspace_dir": _resolve_workspace_dir, # Config-sourced (from agent-env config) "litellm_api_key": lambda: litellm_api_key_override or config.get_litellm_api_key(), - "litellm_base_url": lambda: config.get_litellm_base_url(), + "litellm_base_url": lambda: host_url_for(config.get_litellm_base_url(), sandbox_type), } @@ -240,6 +242,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: workspace_dir=self.workspace_dir, agent_name=self.agent_name, litellm_api_key_override=user_overrides.get("litellm_api_key"), + # A host install on a local sandbox runs on this machine itself, not in a container. + sandbox_type=None if host_mode else sandbox.type, ) resolved: dict[str, str] = {} for name in required_params: diff --git a/src/agent_env/task_step/task_steps/load_artifact.py b/src/agent_env/task_step/task_steps/load_artifact.py index b1564c83..aa298d6b 100644 --- a/src/agent_env/task_step/task_steps/load_artifact.py +++ b/src/agent_env/task_step/task_steps/load_artifact.py @@ -12,6 +12,7 @@ from typing import Any, Callable, ClassVar, Optional from urllib.parse import unquote, urlparse +from agent_env.providers.sandbox_providers.sandbox_provider import reachable_url from agent_env.task_step.context import TaskStepContext from agent_env.entity_refs import EntityRef from agent_env.task_step.task_step import TaskStep, TaskStepDependency @@ -490,7 +491,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: if agent is None or not agent.instance_id: raise RuntimeError(f"Agent '{self.agent_name}' not found in context.deployed_agents (or missing instance_id)") deployed_agent = get_a2a_agent_instance_store().get(agent.instance_id) - gateway_url = require_gateway_url(deployed, "Installing a CliArtifact") + gateway_url = reachable_url(require_gateway_url(deployed, "Installing a CliArtifact"), + from_sandbox_type=deployed.sandbox_type, to_sandbox_type=agent.sandbox_type) install_path = await A2AAgent.install_cli(deployed_agent, artifact, gateway_url) agent_clis = context.metadata.setdefault("installed_clis", {}).setdefault(self.agent_name, {}) agent_clis[artifact.id] = { diff --git a/src/agent_env/task_step/task_steps/peer_agents.py b/src/agent_env/task_step/task_steps/peer_agents.py index 8e52f74a..268d32c5 100644 --- a/src/agent_env/task_step/task_steps/peer_agents.py +++ b/src/agent_env/task_step/task_steps/peer_agents.py @@ -79,7 +79,7 @@ async def _post(self, deployed_by_name: dict[str, DeployedAgent], peering: Agent a2a_url = src.a2a_url or src.api_url ext = a2a_agent_cls.find_extension(src.a2a_card or {}, a2a_agent_cls.EXT_PEER_AGENTS) endpoint = (ext.get("params") or {}).get("endpoint", "/ext/peer-agents") - peers = [self._record(deployed_by_name[n]) for n in peering.peer_agent_names] + peers = [self._record(deployed_by_name[n], src) for n in peering.peer_agent_names] async with httpx.AsyncClient() as client: resp = await client.post(a2a_url + endpoint, json={"peers": peers}, timeout=_POST_TIMEOUT_SECONDS) if resp.status_code >= 400: @@ -87,6 +87,8 @@ async def _post(self, deployed_by_name: dict[str, DeployedAgent], peering: Agent logger.info("PeerAgentsTaskStep: peered '%s' with %s", peering.source_agent_name, peering.peer_agent_names) @staticmethod - def _record(peer: DeployedAgent) -> dict: + def _record(peer: DeployedAgent, source: DeployedAgent) -> dict: + """``peer``'s entry in ``source``'s table.""" card = peer.a2a_card or {} - return {"name": peer.agent_name, "url": peer.a2a_url or peer.api_url, "card": card, "description": card.get("description")} + url = peer.url_for(source.sandbox_type, on_host=source.on_host) + return {"name": peer.agent_name, "url": url, "card": card, "description": card.get("description")} diff --git a/src/agent_env/task_step/task_steps/register_env_triggers.py b/src/agent_env/task_step/task_steps/register_env_triggers.py index f7dc57d7..2bbdbf01 100644 --- a/src/agent_env/task_step/task_steps/register_env_triggers.py +++ b/src/agent_env/task_step/task_steps/register_env_triggers.py @@ -7,6 +7,7 @@ import httpx +from agent_env.env.env import DeployedSandboxEnv from agent_env.env.gateway.constants import EXT_TRIGGERS_URI from agent_env.task_step.context import TaskStepContext from agent_env.entity_refs import EntityRef @@ -80,7 +81,10 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: raise RuntimeError( f"Executor agent role '{agent.role}' is in watch_roles {self.watch_roles} — " f"the executor's own tool calls would fire triggers (cascade).") - body["executor"] = {"a2a_url": agent.a2a_url or agent.api_url, + # The env's gateway calls the executor; an env outside our sandboxes gets its URL as it is. + executor_url = (agent.url_for(deployed.sandbox_type) if isinstance(deployed, DeployedSandboxEnv) + else agent.a2a_url or agent.api_url) + body["executor"] = {"a2a_url": executor_url, "timeout_seconds": self.executor_timeout_seconds, "role": agent.role} try: diff --git a/src/agent_env/task_step/task_steps/run_docker_container.py b/src/agent_env/task_step/task_steps/run_docker_container.py index 9c8013d0..bbf42729 100644 --- a/src/agent_env/task_step/task_steps/run_docker_container.py +++ b/src/agent_env/task_step/task_steps/run_docker_container.py @@ -218,10 +218,11 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: device_flags = " ".join(f"--device {shlex.quote(d)}" for d in self.devices) cap_flags = " ".join(f"--cap-add {shlex.quote(c)}" for c in self.cap_add) volume_flags = " ".join(f"-v {shlex.quote(v)}" for v in self.volumes) + host_flags = " ".join(f"--add-host {shlex.quote(entry)}" for entry in sandbox.extra_hosts) extra_flags = " ".join(f for f in ( "--privileged" if self.privileged else "", f"--shm-size {shlex.quote(self.shm_size)}" if self.shm_size else "", - device_flags, cap_flags, volume_flags, + device_flags, cap_flags, volume_flags, host_flags, ) if f) entrypoint_flag = "" if self.command_override: diff --git a/src/agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py b/src/agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py index e89b4813..c4947153 100644 --- a/src/agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +++ b/src/agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py @@ -33,6 +33,7 @@ import uuid from typing import Any, ClassVar, Optional +from agent_env.providers.sandbox_providers.local_sandbox import host_url_for from agent_env.providers.sandbox_providers.sandbox_provider import ( all_sandbox_container_env, registered_sandbox_provider_classes, @@ -66,6 +67,7 @@ "LITELLM_BASE_URL", "ANTHROPIC_BASE_URL", "PATH", "HOME", }) +_MODEL_URL_KEYS = frozenset({"LITELLM_BASE_URL", "ANTHROPIC_BASE_URL"}) class RunContainerUnitTestsVerifierTaskStep(TaskStep): @@ -253,6 +255,8 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: command, extra_env = self._resolve_command(context) merged_env.update(self.env_vars) merged_env.update(extra_env) + for key in _MODEL_URL_KEYS & merged_env.keys(): + merged_env[key] = host_url_for(merged_env[key], sandbox.type) env_flags = " ".join(f"-e {k}={shlex.quote(v)}" for k, v in merged_env.items()) # 3. Run setup_commands (fail loud) diff --git a/tst/unit/providers/env_providers/env_gateway_provider_test.py b/tst/unit/providers/env_providers/env_gateway_provider_test.py index 3cf1b74d..6acc8d4c 100644 --- a/tst/unit/providers/env_providers/env_gateway_provider_test.py +++ b/tst/unit/providers/env_providers/env_gateway_provider_test.py @@ -16,6 +16,7 @@ import httpx import pytest import uvicorn +import yaml from mcp.server.fastmcp import FastMCP from pytest_socket import enable_socket @@ -25,6 +26,7 @@ from agent_env.env.envs.service_db import SERVICE_DB_PORT from agent_env.env.envs.website import WebsiteEnv from agent_env.providers.env_providers import env_gateway_provider, env_provider +from agent_env.providers.env_providers.constants import GATEWAY_SERVICE_NAME from agent_env.providers.env_providers.env_gateway_provider import ( DeployedGateway, MCPServerConfig, @@ -693,6 +695,24 @@ def test_compose_local_still_renders_servicedb(): assert "condition: service_healthy" in compose +@pytest.mark.parametrize("extra_hosts", [(), ("host.docker.internal:host-gateway",)]) +def test_compose_maps_extra_hosts_into_the_gateway(extra_hosts): + """The gateway calls out to agents, as a trigger's executor, so it carries the sandbox's host mappings.""" + from agent_env.env.envs.service_db import ServiceDBConfig + + compose = EnvironmentGatewayProvider().create_docker_compose( + mcp_servers=[MCPServerConfig(image="mcp-slack", environment_name="slack")], + gateway_image="agent-gateway", + state_provider=LocalPostgresStateProvider(service_db_config=ServiceDBConfig()), + state_instance=LocalPostgresStateProvider.default_instance(), + extra_hosts=extra_hosts, + ) + + services = yaml.safe_load(compose)["services"] + mapped = {name: service["extra_hosts"] for name, service in services.items() if "extra_hosts" in service} + assert mapped == ({GATEWAY_SERVICE_NAME: list(extra_hosts)} if extra_hosts else {}) + + # --- sidecar rendering -------------------------------------------------------- class _FakeImageArtifact: diff --git a/tst/unit/providers/sandbox_providers/local_sandbox_test.py b/tst/unit/providers/sandbox_providers/local_sandbox_test.py index 8e79eaef..9591b018 100644 --- a/tst/unit/providers/sandbox_providers/local_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/local_sandbox_test.py @@ -473,8 +473,9 @@ def test_local_never_shares_network_and_externalizes_localhost(): def test_host_gateway_flag_only_where_nothing_provides_the_alias(): """Only Linux lacks a native host.docker.internal; on Rancher an explicit mapping would break it.""" - expected = "--add-host host.docker.internal:host-gateway" if platform.system() == "Linux" else "" - assert LocalSandboxProvider.EXTRA_CONTAINER_RUN_ARGS == expected + linux = platform.system() == "Linux" + assert LocalSandboxProvider.EXTRA_CONTAINER_RUN_ARGS == ("--add-host host.docker.internal:host-gateway" if linux else "") + assert LocalSandbox.extra_hosts == (("host.docker.internal:host-gateway",) if linux else ()) @pytest.mark.asyncio @@ -936,6 +937,22 @@ async def test_an_agent_placed_on_a_local_vm_sandbox_gets_the_local_ca(tmp_path, assert copies == [] and "sleep 2" in script +@pytest.mark.asyncio +@pytest.mark.parametrize("trusted", [True, False]) +async def test_an_agent_placed_on_a_local_vm_sandbox_maps_the_host_alias(tmp_path, monkeypatch, trusted): + """Whether or not the store hands out local grants, the agent reaches this machine by name on Linux.""" + monkeypatch.setattr(a2a_agent_module, "local_grant_trust", (lambda: local_ca().trust_dir) if trusted else (lambda: None)) + monkeypatch.setattr(a2a_agent_module.asyncio, "sleep", _no_sleep) + monkeypatch.setattr(_RecordingLocalSandbox, "extra_hosts", ("host.docker.internal:host-gateway",)) + sandbox = _RecordingLocalSandbox(work_dir=tmp_path) + agent = A2AAgent.__new__(A2AAgent) + agent._sandbox = sandbox + + await agent._run_container("img:v1", 8000, {"K": "v"}) + + assert "--add-host host.docker.internal:host-gateway" in sandbox.scripts[0] + + async def _no_sleep(_seconds): return None diff --git a/tst/unit/providers/sandbox_providers/test_url_rewrites.py b/tst/unit/providers/sandbox_providers/test_url_rewrites.py index b2e6d175..149d0c7b 100644 --- a/tst/unit/providers/sandbox_providers/test_url_rewrites.py +++ b/tst/unit/providers/sandbox_providers/test_url_rewrites.py @@ -7,7 +7,7 @@ from agent_env.config import reset_config from agent_env.env.env import DeployedEnv, DeployedSandboxEnv -from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider, host_url_for from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider from agent_env.providers.sandbox_providers.sandbox_provider import ( SandboxProvider, @@ -89,6 +89,38 @@ def test_local_provider_isolates_network_and_externalizes_localhost(): ) +@pytest.mark.parametrize("url, expected", [ + ("http://localhost:4000/v1", "http://host.docker.internal:4000/v1"), + ("http://127.0.0.1:41000", "http://host.docker.internal:41000"), + ("http://127.0.1.1:41000/a2a", "http://host.docker.internal:41000/a2a"), + ("http://0.0.0.0:4000", "http://host.docker.internal:4000"), + ("http://[::1]:4000/v1", "http://host.docker.internal:4000/v1"), + ("https://LOCALHOST/v1", "https://host.docker.internal/v1"), + ("http://agent@localhost:4000/v1?next=/x#top", "http://agent@host.docker.internal:4000/v1?next=/x#top"), +]) +def test_local_provider_externalizes_a_loopback_host(url, expected): + assert LocalSandboxProvider.get_external_url(url) == expected + + +@pytest.mark.parametrize("url", [ + "https://llm.example.com/proxy/localhost/127.0.0.1", + "https://localhost.example.com/v1", + "http://mylocalhost:4000", + "https://api.example.com/?next=http://localhost:4000", + "http://10.0.0.5:4000", + "http://host.docker.internal:4000", +]) +def test_local_provider_leaves_a_url_whose_host_is_not_loopback(url): + assert LocalSandboxProvider.get_external_url(url) == url + + +@pytest.mark.parametrize("sandbox_type, expected", [ + ("local", "http://host.docker.internal:4000"), ("modal", "http://localhost:4000"), (None, "http://localhost:4000"), +]) +def test_only_a_local_container_reaches_this_machine_by_another_name(sandbox_type, expected): + assert host_url_for("http://localhost:4000", sandbox_type) == expected + + def test_nested_rewrite_keys_apply_longest_first(): # The mac key CONTAINS the base key — rewriting on the shorter key first would yield a host that does not exist. assert _NestedProvider.get_external_url(f"https://t.mac.{_INTERNAL}:443/x") == f"https://t.mac.{_EXTERNAL}:443/x" diff --git a/tst/unit/task_step/local_container_urls_test.py b/tst/unit/task_step/local_container_urls_test.py new file mode 100644 index 00000000..bc7390e5 --- /dev/null +++ b/tst/unit/task_step/local_container_urls_test.py @@ -0,0 +1,264 @@ +"""A URL on this machine that agent-env hands into a container on it reaches the host: an agent's, a unit-test +verifier's and an installed agent's model endpoint, a peer's and a trigger executor's A2A URL, and a CLI's gateway +URL. Containers elsewhere get them as they are.""" + +from __future__ import annotations + +import asyncio +import json +import types + +import httpx +import pytest + +from agent_env.a2a_agent import store as agent_store +from agent_env.a2a_agent.a2a_agent import A2AAgent +from agent_env.artifact.artifacts.cli import CliArtifact +from agent_env.env.env import DeployedGatewayEnv +from agent_env.providers.sandbox_providers import sandbox_provider +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, LocalSandboxProvider +from agent_env.task_step.context import DeployedAgent, DeployedSandbox, TaskStepContext +from agent_env.task_step.task_steps.install_agent import _build_param_resolvers +from agent_env.task_step.task_steps.load_artifact import LoadArtifactTaskStep +from agent_env.task_step.task_steps.peer_agents import PeerAgentsTaskStep +from agent_env.task_step.task_steps.verifiers.run_container_unit_tests_verifier import ( + RunContainerUnitTestsVerifierTaskStep, +) + +MODEL = "http://localhost:4000/v1" +MODEL_IN_CONTAINER = "http://host.docker.internal:4000/v1" + + +@pytest.fixture +def model_endpoint(monkeypatch): + monkeypatch.setenv("LITELLM_BASE_URL", MODEL) + monkeypatch.setenv("LITELLM_API_KEY", "k") + + +class _Started(Exception): + """Raised once a fake has seen the container's env, which is all a test reads.""" + + +class _LocalProvider(LocalSandboxProvider): + async def create_sandbox(self, *, env, **_): + self.env = env + raise _Started + + +class _RemoteProvider: + async def create_sandbox(self, *, env, **_): + self.env = env + raise _Started + + +def _agent(**fields) -> A2AAgent: + return A2AAgent(id="solver", version=1, docker_image_artifact=types.SimpleNamespace(image_name="img"), **fields) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_cls, expected", [(_LocalProvider, MODEL_IN_CONTAINER), (_RemoteProvider, MODEL)]) +@pytest.mark.parametrize("passed", [False, True], ids=["configured", "passed"]) +async def test_an_agent_gets_the_model_endpoint_its_container_reaches( + monkeypatch, local_stores, model_endpoint, provider_cls, expected, passed, +): + """Whether configured or passed in, as a deploy_agent step and an agent judge pass it.""" + provider = provider_cls() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) + + with pytest.raises(_Started): + await _agent().deploy(sandbox_type="any", env_vars={"LITELLM_BASE_URL": MODEL} if passed else None) + + assert provider.env["LITELLM_BASE_URL"] == expected + + +@pytest.mark.asyncio +async def test_an_agent_placed_on_a_local_sandbox_reaches_the_model_endpoint(monkeypatch, local_stores, model_endpoint, tmp_path): + started = {} + + async def run_container(self, image_name, a2a_port, merged_env, enable_docker=False): + started.update(merged_env) + raise _Started + + async def no_images(artifacts): + return None + + sandbox = LocalSandbox(work_dir=tmp_path) + sandbox.load_docker_images = no_images + monkeypatch.setattr(A2AAgent, "_run_container", run_container) + + with pytest.raises(_Started): + await _agent().deploy(sandbox=sandbox) + + assert started["LITELLM_BASE_URL"] == MODEL_IN_CONTAINER + + +@pytest.mark.asyncio +async def test_an_agents_own_model_endpoint_is_left_as_it_declares(monkeypatch, local_stores, model_endpoint): + provider = _LocalProvider() + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) + + with pytest.raises(_Started): + await _agent(default_env_vars={"LITELLM_BASE_URL": "http://localhost:9000"}).deploy(sandbox_type="local") + + assert provider.env["LITELLM_BASE_URL"] == "http://localhost:9000" + + +class _VerifierSandbox: + def __init__(self, sandbox_type: str): + self.type = sandbox_type + self.commands: list[str] = [] + + async def exec_script(self, script: str) -> str: + self.commands.append(script) + return "" + + async def exec_with_output(self, *args) -> tuple[int, str, str]: + self.commands.append(args[-1]) + return 0, "ok", "" + + +def _run_verifier(monkeypatch, sandbox_type: str, **step_fields) -> str: + """The verifier's test command, as it ran on a sandbox of ``sandbox_type``.""" + sandbox = _VerifierSandbox(sandbox_type) + provider = types.SimpleNamespace(get_sandbox=lambda sandbox_id: asyncio.sleep(0, result=sandbox)) + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", lambda name: provider) + monkeypatch.setattr(RunContainerUnitTestsVerifierTaskStep, "_upload_text_artifact", staticmethod( + lambda text, artifact_id, description, object_url: types.SimpleNamespace( + id=artifact_id, version=1, object_url=object_url))) + context = TaskStepContext() + context.metadata["deployed_docker_containers"] = [{"container_name": "c", "sandbox_name": "h"}] + context.deployed_sandboxes.append( + DeployedSandbox(sandbox_name="h", sandbox_id="sb-1", sandbox_mode="vm", sandbox_type=sandbox_type)) + + asyncio.run(RunContainerUnitTestsVerifierTaskStep( + id="tests", version=None, sandbox_name="h", container_name="c", command="true", **step_fields).execute(context)) + + [run] = [c for c in sandbox.commands if "timeout --kill-after" in c] + return run + + +@pytest.mark.parametrize("sandbox_type, expected", [("local", MODEL_IN_CONTAINER), ("modal_vm", MODEL)]) +def test_a_unit_test_verifier_gets_the_model_endpoint_its_container_reaches( + monkeypatch, local_stores, model_endpoint, sandbox_type, expected, +): + run = _run_verifier(monkeypatch, sandbox_type) + + assert f"-e LITELLM_BASE_URL={expected} " in run and f"-e ANTHROPIC_BASE_URL={expected} " in run + + +def test_a_model_endpoint_the_verifier_sets_itself_is_reached_through_the_host(monkeypatch, local_stores, model_endpoint): + run = _run_verifier(monkeypatch, "local", env_vars={"LITELLM_BASE_URL": "http://127.0.0.1:5001"}) + + assert "-e LITELLM_BASE_URL=http://host.docker.internal:5001 " in run + + +@pytest.mark.parametrize("sandbox_type, expected", [ + ("local", MODEL_IN_CONTAINER), ("modal_vm", MODEL), (None, MODEL), +], ids=["local-container", "remote-container", "host-install"]) +def test_an_installed_agent_gets_the_model_endpoint_it_reaches(sandbox_type, expected): + resolvers = _build_param_resolvers( + container_name="c", work_dir="/w", agent_ctx_tar="/w/a.tgz", a2a_port=8000, agent_name="solver", + config=types.SimpleNamespace(get_litellm_base_url=lambda: MODEL), sandbox_type=sandbox_type, + ) + + assert resolvers["litellm_base_url"]() == expected + + +_PEERS_CARD = {"capabilities": {"extensions": [ + {"uri": A2AAgent.EXT_PEER_AGENTS, "params": {"endpoint": "/ext/peer-agents"}}]}} +_HUMAN = "http://localhost:18000/api/v1/a2a/human/instance/i-1" + + +def _record_posts(monkeypatch) -> list[httpx.Request]: + sent, real = [], httpx.AsyncClient + + def handle(request: httpx.Request) -> httpx.Response: + sent.append(request) + return httpx.Response(200, json={}) + + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: real(transport=httpx.MockTransport(handle))) + return sent + + +def _peering_context(source_type: str) -> TaskStepContext: + return TaskStepContext(deployed_agents=[ + DeployedAgent(agent_name="source", api_url="http://source", a2a_url="http://source", sandbox_id="sb-source", + sandbox_type=source_type, a2a_card=_PEERS_CARD), + DeployedAgent(agent_name="local", api_url="http://127.0.0.1:41001", a2a_url="http://127.0.0.1:41001", + sandbox_id="local-1", sandbox_type="local"), + DeployedAgent(agent_name="human", api_url=_HUMAN, a2a_url=_HUMAN), + DeployedAgent(agent_name="remote", api_url="https://peer.modal.run", a2a_url="https://peer.modal.run", + sandbox_id="sb-2", sandbox_type="modal"), + ]) + + +async def _peer_urls(monkeypatch, source_type: str) -> dict[str, str]: + sent = _record_posts(monkeypatch) + step = PeerAgentsTaskStep(id="peers", version=None, peerings=[ + {"source_agent_name": "source", "peer_agent_names": ["local", "human", "remote"]}]) + + await step.execute(_peering_context(source_type)) + + [request] = sent + return {peer["name"]: peer["url"] for peer in json.loads(request.content)["peers"]} + + +@pytest.mark.asyncio +async def test_a_local_agent_reaches_its_peers_on_this_machine_through_the_host(monkeypatch): + assert await _peer_urls(monkeypatch, "local") == { + "local": "http://host.docker.internal:41001", + "human": "http://host.docker.internal:18000/api/v1/a2a/human/instance/i-1", + "remote": "https://peer.modal.run", + } + + +@pytest.mark.asyncio +async def test_an_agent_installed_on_a_local_sandboxs_host_gets_peer_urls_as_they_are(monkeypatch): + sent = _record_posts(monkeypatch) + context = _peering_context("local") + context.deployed_agents[0].on_host = True + + await PeerAgentsTaskStep(id="peers", version=None, peerings=[ + {"source_agent_name": "source", "peer_agent_names": ["local", "human"]}]).execute(context) + + assert [peer["url"] for peer in json.loads(sent[0].content)["peers"]] == ["http://127.0.0.1:41001", _HUMAN] + + +@pytest.mark.asyncio +async def test_a_remote_agent_gets_peer_urls_from_beyond_this_machine_as_they_are(monkeypatch): + urls = await _peer_urls(monkeypatch, "modal") + + assert (urls["human"], urls["remote"]) == (_HUMAN, "https://peer.modal.run") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sandbox_type, gateway_url, expected", [ + ("local", "http://127.0.0.1:41500", "http://host.docker.internal:41500"), + ("modal_vm", "https://gw.modal.host", "https://gw.modal.host"), +]) +async def test_a_cli_gets_the_gateway_url_its_agent_reaches( + monkeypatch, local_stores, tmp_path, sandbox_type, gateway_url, expected, +): + (tmp_path / "cli").mkdir() + (tmp_path / "cli" / "run.sh").write_text("#!/bin/sh\n") + cli = CliArtifact.put("slack-cli", command_name="slack", entrypoint="run.sh", cli_dir=tmp_path / "cli") + installed = [] + + async def install_cli(deployed, cli_artifact, gateway_url): + installed.append(gateway_url) + return "/opt/cli/slack/run.sh" + + monkeypatch.setattr(A2AAgent, "install_cli", staticmethod(install_cli)) + monkeypatch.setattr(agent_store, "get_a2a_agent_instance_store", + lambda: types.SimpleNamespace(get=lambda instance_id: object())) + context = TaskStepContext( + deployed_envs=[DeployedGatewayEnv(env_id="env-x", env_version=1, gateway_url=gateway_url, mcp_url="", + sandbox_id="sb-env", sandbox_type=sandbox_type)], + deployed_agents=[DeployedAgent(agent_name="solver", api_url="http://agent", sandbox_id="sb-agent", + sandbox_type=sandbox_type, instance_id="inst-1")], + ) + + await LoadArtifactTaskStep(id="cli", version=None, env_id="env-x", artifact_id=cli.id, + artifact_version=cli.version, agent_name="solver").execute(context) + + assert installed == [expected] diff --git a/tst/unit/task_step/run_docker_container_test.py b/tst/unit/task_step/run_docker_container_test.py index ba92180c..4b1fa420 100644 --- a/tst/unit/task_step/run_docker_container_test.py +++ b/tst/unit/task_step/run_docker_container_test.py @@ -216,8 +216,9 @@ async def test_a_store_context_that_is_not_a_zip_is_refused(fake_store): class _RunSandbox: sandbox_id = "local-1" - def __init__(self): + def __init__(self, extra_hosts: tuple[str, ...] = ()): self.scripts: list[str] = [] + self.extra_hosts = extra_hosts async def exec_script(self, script): self.scripts.append(script) @@ -240,3 +241,21 @@ async def test_what_it_starts_is_labeled_with_its_sandbox(monkeypatch): for command in ("docker build", "docker run -d", "docker network create"): (script,) = [s for s in sandbox.scripts if command in s] assert "--label agentenv.sandbox=local-1" in script, command + + +@pytest.mark.asyncio +@pytest.mark.parametrize("extra_hosts", [(), ("host.docker.internal:host-gateway",)]) +async def test_what_it_starts_maps_the_sandboxs_extra_hosts(monkeypatch, extra_hosts): + """On Linux a local sandbox maps host.docker.internal, so the container reaches URLs on this machine.""" + sandbox = _RunSandbox(extra_hosts) + monkeypatch.setattr(sandbox_provider, "build_sandbox_provider", + lambda _type: SimpleNamespace(get_sandbox=AsyncMock(return_value=sandbox))) + monkeypatch.setattr(Step, "_stage_from_universe", AsyncMock()) + context = TaskStepContext(deployed_sandboxes=[ + DeployedSandbox(sandbox_name="h", sandbox_id="local-1", sandbox_mode="vm", sandbox_type="local"), + ]) + + await _step().execute(context) + + (run,) = [s for s in sandbox.scripts if "docker run -d" in s] + assert ("--add-host host.docker.internal:host-gateway" in run) is bool(extra_hosts) diff --git a/tst/unit/task_step/test_register_env_triggers.py b/tst/unit/task_step/test_register_env_triggers.py index 6ddb753b..34abb12c 100644 --- a/tst/unit/task_step/test_register_env_triggers.py +++ b/tst/unit/task_step/test_register_env_triggers.py @@ -76,6 +76,27 @@ async def test_resolves_executor_agent_to_a2a_url(monkeypatch): "a2a_url": "http://exec:9000/a2a-base", "timeout_seconds": 45, "role": "executor"} +@pytest.mark.asyncio +@pytest.mark.parametrize("in_our_sandbox, expected", [ + (True, "http://host.docker.internal:41234"), (False, "http://127.0.0.1:41234"), +], ids=["local-gateway", "env-outside-our-sandboxes"]) +async def test_a_local_gateway_reaches_a_local_executor_through_the_host(monkeypatch, in_our_sandbox, expected): + sent = _mock_gateway(monkeypatch, {"ok": True, "added": ["t1"], "all": ["t1"]}) + env = _record() + if in_our_sandbox: + env.sandbox_type = "local" + else: + env = DeployedEnv(env_id=env.env_id, env_version=1, mcp_url="", environment_card_url=env.environment_card_url, + environment_card=env.environment_card) + executor = DeployedAgent(agent_name="exec-1", api_url="http://127.0.0.1:41234", a2a_url="http://127.0.0.1:41234", + sandbox_id="local-a", sandbox_type="local", role="executor") + context = TaskStepContext(deployed_envs=[env], deployed_agents=[executor], metadata={}) + + await _step(executor_agent_name="exec-1", watch_roles=["default"]).execute(context) + + assert json.loads(sent[0].content)["executor"]["a2a_url"] == expected + + @pytest.mark.asyncio async def test_missing_executor_agent_raises(): ctx = _context() # no agents deployed