From 59fc56e836a0595cf880a1f26c7e6fefd07e4d88 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 22:35:37 -0700 Subject: [PATCH 1/4] refactor(sandbox): host URLs, local grants and the trust CA read ON_THIS_MACHINE, so a VM provider on this machine needs no core edits A provider whose sandboxes run on this machine in VMs, not on this machine's Docker, needed four edits outside its module: host_url_for and the local store's grants_reach were keyed on the provider's type name, start_trusting ran docker cp on this machine, and the local grant server listened only where this machine's containers reach it. Core now reads what the provider declares. HOST_ADDRESS says where a sandbox's requests to this machine arrive: None for containers on this machine's Docker, a loopback address for a VM here whose networking forwards this machine's names to it. on_this_machine_provider resolves a type to its class when that class declares ON_THIS_MACHINE, and None for an unknown type or one whose extra isn't installed. host_url_for and grants_reach use it; grants_reach also refuses a grant URL naming this machine's loopback, which inside any sandbox is the sandbox itself. start_trusting installs the CA through a VmSandbox hook, and the grant server serves the platform default on loopback too. The trust hook and the loopback bind are lifted from #157. Co-authored-by: BinBin He <8340774+BinSquare@users.noreply.github.com> Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- .../sandbox_providers/local_sandbox.py | 19 +++++--- .../providers/sandbox_providers/sandbox.py | 13 +++++ .../sandbox_providers/sandbox_provider.py | 22 ++++++++- .../store/object_store/local/grant_server.py | 44 ++++++++++++----- .../store/object_store/local/store.py | 39 +++++++++++++-- .../provider_capabilities_test.py | 15 +++++- .../sandbox_providers/test_url_rewrites.py | 25 +++++++++- .../sandbox_providers/vm_sandbox_test.py | 31 ++++++++++++ tst/unit/store/local_grant_server_test.py | 27 +++++++++++ tst/unit/store/test_object_transfer_grants.py | 48 ++++++++++++++++++- 11 files changed, 259 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 082c2fa2..5a892e03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ and deserialized through a registry. | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, and more). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. A new backend must pass the conformance kits in `tst/store/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | -| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `tensorlake` (the `tensorlake` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | +| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `tensorlake` (the `tensorlake` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine where their requests to it arrive, `HOST_ADDRESS`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. | | `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. | diff --git a/src/agent_env/providers/sandbox_providers/local_sandbox.py b/src/agent_env/providers/sandbox_providers/local_sandbox.py index 3ece7364..abec670b 100644 --- a/src/agent_env/providers/sandbox_providers/local_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/local_sandbox.py @@ -44,6 +44,7 @@ SANDBOX_MODE_VM, Accepts, SandboxProvider, + on_this_machine_provider, refuse_unenforceable_policy, ) from agent_env.store.object_store.local.tls import local_ca @@ -259,6 +260,10 @@ def _rewrite_app_arg(self, arg: str) -> str: def _rewrite_app_script(self, script: str) -> str: return _APP_PATH_PATTERN.sub(lambda _: str(self._work_dir), script) + async def copy_trust_dir_into_container(self, trust_dir: Path, container: str, destination: str) -> None: + """Directly: the container is on this machine's Docker, and this sandbox's shell would rewrite the path.""" + await asyncio.to_thread(_copy_into_container, trust_dir, container, destination) + async def terminate(self) -> None: """Tear down whatever this sandbox is running. @@ -504,10 +509,11 @@ def local_grant_trust() -> Path | None: async def start_trusting(sandbox: VmSandbox, container: str, trust_dir: Path) -> None: - """Copy ``trust_dir`` into the created ``container`` where ``LOCAL_TRUST_ENV`` points, then start it. A container - that cannot be given the files or started is removed, so its name is free for the next attempt.""" + """Copy ``trust_dir`` into the created ``container`` where ``LOCAL_TRUST_ENV`` points, as the sandbox's host can + reach it, then start it. A container that cannot be given the files or started is removed, so its name is free + for the next attempt.""" try: - await asyncio.to_thread(_copy_into_container, trust_dir, container, _TRUST_DIR) + await sandbox.copy_trust_dir_into_container(trust_dir, container, _TRUST_DIR) await sandbox.exec_script(f"docker start {shlex.quote(container)} > /dev/null") except Exception: await sandbox.exec_script(f"docker rm -f {shlex.quote(container)} >/dev/null 2>&1 || true") @@ -682,9 +688,10 @@ def get_external_url(cls, url: str) -> str: 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 + """``url``, which this machine reaches, as a container on ``sandbox_type`` reaches it: one on this machine (its + provider's ``ON_THIS_MACHINE``) reaches this machine's loopback as ``host.docker.internal``; one elsewhere reaches + the URL as it is, or not at all.""" + return LocalSandboxProvider.get_external_url(url) if on_this_machine_provider(sandbox_type) else url def _is_loopback(host: Optional[str]) -> bool: diff --git a/src/agent_env/providers/sandbox_providers/sandbox.py b/src/agent_env/providers/sandbox_providers/sandbox.py index 3016b919..9c15175e 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/sandbox.py @@ -19,6 +19,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, replace from enum import Enum +from pathlib import Path from typing import IO, TYPE_CHECKING, Any, AsyncIterator, Callable, ClassVar, Iterable, Optional from agent_env.config import get_config @@ -477,6 +478,18 @@ async def docker_cp(self, source: str, destination: str, *, remove_source: bool f"docker cp {source} {destination} failed (exit {exit_code}):\nstdout: {stdout[-1500:]}\nstderr: {stderr[-1500:]}" ) + async def copy_trust_dir_into_container(self, trust_dir: Path, container: str, destination: str) -> None: + """Install the local transfer CA's files, ``trust_dir`` on this machine, at ``destination`` in ``container`` on + this VM's Docker: through the VM, where the container is.""" + vm_dir = self._staging_path("trust", container) + await self.exec_script(f"mkdir -p {shlex.quote(vm_dir)}") + try: + for path in sorted(trust_dir.iterdir()): + await self.write_host_file(await asyncio.to_thread(path.read_bytes), f"{vm_dir}/{path.name}") + await self.docker_cp(f"{vm_dir}/.", f"{container}:{destination}") + finally: + await self.exec_script(f"rm -rf {shlex.quote(vm_dir)}") + async def _copy_into_container(self, vm_path: str, destination_path: str) -> None: parent = os.path.dirname(destination_path) if parent: diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 8d74fe72..058e9e14 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -171,8 +171,15 @@ def from_config(cls, **config: Any) -> Self: # sandbox the images that follow from what it implements (SANDBOX_ACCEPTS). # # Whether its sandboxes run on this machine: an image only this machine holds (in a registry or object store on - # it) reaches them, its containers need this machine's Docker, and a gateway's infra envs are built here for it. + # it) reaches them, they reach this machine's loopback as ``host.docker.internal`` (resolved by Docker, or by the + # provider's ``extra_hosts``), they trust the local transfer CA, and a gateway's infra envs are built here for + # them. They are containers on this machine's Docker, or VMs here (``HOST_ADDRESS``). ON_THIS_MACHINE: ClassVar[bool] = False + # For a provider on this machine, where a request from its sandboxes to this machine's names arrives: None for + # containers on this machine's Docker, at the address ``host.docker.internal`` maps to, where the local grant + # server listens by default; a loopback address for a VM here whose networking forwards this machine's names to + # it, which the server listens on too by default, and from which this machine's addresses by number aren't reached. + HOST_ADDRESS: ClassVar[str | None] = None # The sandbox modes it creates, derived for each provider class from what it implements (``__init_subclass__``): # SANDBOX_MODE_CONTAINER when it implements create_sandbox, SANDBOX_MODE_VM when it implements create_vm. SUPPORTED_SANDBOX_MODES: ClassVar[frozenset[str]] = frozenset() @@ -438,6 +445,19 @@ def reachable_url(url: str, *, from_sandbox_type: Optional[str], to_sandbox_type return provider_cls.get_external_url(url) +def on_this_machine_provider(sandbox_type: Optional[str]) -> Optional[type[SandboxProvider]]: + """The provider class registered for ``sandbox_type`` when its sandboxes run on this machine (``ON_THIS_MACHINE``), + else None: for no type, an unknown one, or one whose provider can't be loaded here, which reaches nothing of + this machine's.""" + if not sandbox_type: + return None + try: + cls = _sandbox_provider_class(sandbox_type) + except (ValueError, ConfigError): + return None + return cls if cls.ON_THIS_MACHINE else None + + def registered_sandbox_provider_classes() -> list[type[SandboxProvider]]: """Every registered provider class, de-duplicated, in registry order. diff --git a/src/agent_env/store/object_store/local/grant_server.py b/src/agent_env/store/object_store/local/grant_server.py index 6647e7d5..37dfdb67 100644 --- a/src/agent_env/store/object_store/local/grant_server.py +++ b/src/agent_env/store/object_store/local/grant_server.py @@ -51,6 +51,7 @@ DEFAULT_ADVERTISE_HOST = "host.docker.internal" _START_TIMEOUT_SECONDS = 10 +_BIND_ATTEMPTS = 5 # a second socket on one port can lose the port to another process in between _OPS_BY_METHOD = {"GET": "get", "HEAD": "get", "PUT": "put", "POST": "post"} _UPLOAD_KEY_FIELD = "key" # the form fields a policy upload names its key and file by (HttpPostPolicyGrant's path/file fields) _UPLOAD_FILE_FIELD = "file" @@ -59,7 +60,7 @@ _MAX_PART_HEADER_BYTES = 16 * 1024 _DOWNLOAD_CHUNK_BYTES = 1024 * 1024 -_servers: dict[tuple[str, str], GrantServer] = {} +_servers: dict[tuple[str, str, bool], GrantServer] = {} _servers_lock = threading.Lock() @@ -82,7 +83,10 @@ def __init__(self, status: int, reason: str) -> None: def grant_server(bind_host: str | None, advertise_host: str | None) -> GrantServer: """This process's grant server for a bind and advertised host, created on first use; neither given means this platform's default bind address and ``host.docker.internal``.""" - key = (bind_host or default_bind_host(), advertise_host or DEFAULT_ADVERTISE_HOST) + resolved = bind_host or default_bind_host() + # The platform default is served on loopback too, where a VM on this machine reaches it; an explicit bind + # host is served as given. + key = (resolved, advertise_host or DEFAULT_ADVERTISE_HOST, bind_host is None and resolved != "127.0.0.1") with _servers_lock: server = _servers.get(key) if server is None: @@ -130,13 +134,20 @@ def default_bind_host() -> str: return "127.0.0.1" +def _close_all(sockets: list[socket.socket]) -> None: + for sock in sockets: + sock.close() + sockets.clear() + + class GrantServer: """Serves the grants of the local stores registered with it, over HTTPS on one port. Started by the first grant it issues; it runs in a daemon thread for the rest of the process.""" - def __init__(self, bind_host: str, advertise_host: str) -> None: + def __init__(self, bind_host: str, advertise_host: str, also_loopback: bool = False) -> None: self.bind_host = bind_host self.advertise_host = advertise_host + self.also_loopback = also_loopback self._signer = GrantSigner() self._stores: dict[str, LocalFilesystemObjectStore] = {} self._lock = threading.Lock() @@ -191,17 +202,28 @@ def _start(self) -> int: with self._lock: if self._port is not None: return self._port - address = ipaddress.ip_address(self.bind_host) - sock = socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_STREAM) + bind_hosts = [self.bind_host] + (["127.0.0.1"] if self.also_loopback else []) + sockets: list[socket.socket] = [] try: - sock.bind((self.bind_host, 0)) + for attempt in range(_BIND_ATTEMPTS if len(bind_hosts) > 1 else 1): + try: + for host in bind_hosts: # the first picks the port, the rest bind it + family = socket.AF_INET6 if ipaddress.ip_address(host).version == 6 else socket.AF_INET + sock = socket.socket(family, socket.SOCK_STREAM) + sockets.append(sock) + sock.bind((host, sockets[0].getsockname()[1] if len(sockets) > 1 else 0)) + break + except OSError: + _close_all(sockets) + if attempt == _BIND_ATTEMPTS - 1: + raise hosts = ["localhost", "host.docker.internal", "127.0.0.1", self.advertise_host] with contextlib.suppress(ValueError): # a name the CA cannot vouch for would void the certificate check_local_host(self.bind_host) hosts.append(self.bind_host) context = server_context(local_ca(), hosts) except (OSError, ValueError) as e: - sock.close() + _close_all(sockets) raise GrantUnavailableError(f"the local grant server cannot listen on {self.bind_host}: {e}") from e config = uvicorn.Config( self._app, lifespan="off", log_config=None, http=_UnloggedH11Protocol, @@ -212,7 +234,7 @@ def _start(self) -> int: def serve() -> None: try: - asyncio.run(server.serve(sockets=[sock])) + asyncio.run(server.serve(sockets=sockets)) except BaseException as e: # uvicorn ends a failed startup with SystemExit failure.append(e) @@ -222,15 +244,15 @@ def serve() -> None: while not server.started: if failure or not thread.is_alive() or time.monotonic() > deadline: server.should_exit = True - sock.close() + _close_all(sockets) raise GrantUnavailableError( f"the local grant server did not start on {self.bind_host}" + (f": {failure[0]!r}" if failure else "") ) time.sleep(0.01) self._uvicorn, self._thread = server, thread - self._port = sock.getsockname()[1] - logger.info("Local grant server listening on %s:%d", self.bind_host, self._port) + self._port = sockets[0].getsockname()[1] + logger.info("Local grant server listening on %s:%d", " and ".join(bind_hosts), self._port) return self._port async def _handle(self, request: Request) -> Response: diff --git a/src/agent_env/store/object_store/local/store.py b/src/agent_env/store/object_store/local/store.py index fe4860d6..7a02ea75 100644 --- a/src/agent_env/store/object_store/local/store.py +++ b/src/agent_env/store/object_store/local/store.py @@ -26,7 +26,7 @@ if os.name != "nt": import fcntl from agent_env.store.local_state import ensure_state_dir -from agent_env.store.object_store.local.grant_server import grant_server +from agent_env.store.object_store.local.grant_server import DEFAULT_ADVERTISE_HOST, grant_server from agent_env.store.object_store.local.tls import check_local_host from agent_env.store.object_store.local.tokens import Op from agent_env.store.object_store.object_store import ( @@ -50,8 +50,6 @@ _STAGED_GRACE_SECONDS = 60 # a staged file younger than this may not be locked by its writer yet _GRANT_MODES = ("auto", "off") _MAX_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60 # a hosted store's longest signature; a grant cannot outlive its process anyway -_LOCAL_SANDBOX_TYPE = "local" # LocalSandbox.type: containers on this host's Docker, which trust the local CA - logger = logging.getLogger(__name__) _META_DIR = ".agentenv-meta" # each object's content type, at the object's own key @@ -194,7 +192,24 @@ def get_object_key(self, object_url: str) -> str: return path.relative_to(root).as_posix() def grants_reach(self, sandbox_type: str | None) -> bool: - return sandbox_type == _LOCAL_SANDBOX_TYPE + """Whether a sandbox of ``sandbox_type`` reaches this store's grant server, as only one on this machine does + (its provider's ``ON_THIS_MACHINE``): when the server listens where the sandbox's requests arrive and the grant + URL names this machine by a name the sandbox resolves: not its loopback by number or as ``localhost``, which + inside any sandbox is the sandbox's own. A container on this machine's Docker reaches the address + ``host.docker.internal`` maps to, so any bind host. A VM here reaches the loopback address its provider + declares (``HOST_ADDRESS``), served by default or when bound there, and no address by number, which there is + the VM's own.""" + from agent_env.providers.sandbox_providers.sandbox_provider import on_this_machine_provider # providers import this module + + provider = on_this_machine_provider(sandbox_type) + if provider is None: + return False + advertised = self._grant_advertise_host or DEFAULT_ADVERTISE_HOST + if _is_loopback_literal(advertised): + return False + if provider.HOST_ADDRESS is None: + return True + return self._grant_bind_host in (None, provider.HOST_ADDRESS) and not _is_address(advertised) def issue_read_grant(self, object_url: str, *, expires_in: int | None = None) -> HttpGetGrant: expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in @@ -428,3 +443,19 @@ def _missing_as_not_found(object_url: str) -> Iterator[None]: yield except (FileNotFoundError, IsADirectoryError, NotADirectoryError) as e: raise ObjectNotFoundError(f"No object at {object_url}.") from e + + +def _is_address(host: str) -> bool: + """Whether ``host`` is an IP address rather than a name.""" + try: + ipaddress.ip_address(host) + except ValueError: + return False + return True + + +def _is_loopback_literal(host: str) -> bool: + """Whether ``host`` names this machine from itself: ``localhost``, a loopback address or the unspecified one.""" + if host == "localhost": + return True + return _is_address(host) and (ipaddress.ip_address(host).is_loopback or ipaddress.ip_address(host).is_unspecified) diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 33001b84..d5127312 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -25,6 +25,7 @@ SANDBOX_MODE_VM, Accepts, SandboxProvider, + on_this_machine_provider, ) from agent_env.providers.sandbox_providers.tensorlake.provider import TensorlakeSandboxProvider from agent_env.providers.sandbox_providers.tensorlake.sandbox import TensorlakeSandbox @@ -68,7 +69,7 @@ async def create_vm(self, **_): def test_a_provider_that_declares_nothing_has_what_it_implements_and_loads_an_agents_image_on_a_vm(): - assert (_Vms.ON_THIS_MACHINE, _Vms.PRIVATE_NETWORK) == (False, False) + assert (_Vms.ON_THIS_MACHINE, _Vms.HOST_ADDRESS, _Vms.PRIVATE_NETWORK) == (False, None, False) assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (LOADABLE, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" @@ -126,6 +127,18 @@ def test_an_agents_sandbox_takes_what_create_sandbox_gives_unless_the_provider_d assert cls.SANDBOX_ACCEPTS is accepts +@pytest.mark.parametrize("sandbox_type, expected", [ + ("local", LocalSandboxProvider), ("modal", None), ("e2b", None), (None, None), ("no-such-provider", None), +]) +def test_the_provider_of_a_type_on_this_machine_is_read_from_the_registry_and_one_elsewhere_or_unknown_is_none( + sandbox_type, expected): + assert on_this_machine_provider(sandbox_type) is expected + + +def test_every_built_in_providers_sandboxes_are_containers_on_this_machines_docker_or_elsewhere(): + assert all(cls.HOST_ADDRESS is None for cls in PROVIDER_CLASSES) + + def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): first, second = _Vms(), ModalSandboxProvider() diff --git a/tst/unit/providers/sandbox_providers/test_url_rewrites.py b/tst/unit/providers/sandbox_providers/test_url_rewrites.py index 149d0c7b..20f5da4a 100644 --- a/tst/unit/providers/sandbox_providers/test_url_rewrites.py +++ b/tst/unit/providers/sandbox_providers/test_url_rewrites.py @@ -116,11 +116,34 @@ def test_local_provider_leaves_a_url_whose_host_is_not_loopback(url): @pytest.mark.parametrize("sandbox_type, expected", [ ("local", "http://host.docker.internal:4000"), ("modal", "http://localhost:4000"), (None, "http://localhost:4000"), + ("no-such-provider", "http://localhost:4000"), ]) -def test_only_a_local_container_reaches_this_machine_by_another_name(sandbox_type, expected): +def test_a_container_on_this_machine_reaches_it_by_another_name_and_one_elsewhere_as_it_is(sandbox_type, expected): assert host_url_for("http://localhost:4000", sandbox_type) == expected +class _Here(SandboxProvider): + """A config-registered provider whose sandboxes run on this machine, as a local VM provider's do.""" + + ON_THIS_MACHINE = True + + async def create_vm(self, **kwargs): # pragma: no cover - never provisioned here + raise NotImplementedError + + +def test_a_provider_that_declares_its_sandboxes_on_this_machine_gets_the_rewrite_without_naming_itself(tmp_path, monkeypatch): + (tmp_path / "config.toml").write_text( + '[sandbox.providers.here]\nimpl = "tst.unit.providers.sandbox_providers.test_url_rewrites:_Here"\n' + ) + monkeypatch.setenv("AGENT_ENV_CONFIG", str(tmp_path / "config.toml")) + reset_config() + try: + assert host_url_for("http://127.0.0.1:4000/mcp", "here") == "http://host.docker.internal:4000/mcp" + assert host_url_for("https://api.example.com/v1", "here") == "https://api.example.com/v1" + finally: + reset_config() + + 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/providers/sandbox_providers/vm_sandbox_test.py b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py index 367829e3..6a54b764 100644 --- a/tst/unit/providers/sandbox_providers/vm_sandbox_test.py +++ b/tst/unit/providers/sandbox_providers/vm_sandbox_test.py @@ -790,3 +790,34 @@ async def test_tarballs_are_loaded_registry_images_pulled_and_contexts_built(sig assert sandbox.scripts[1] == f"docker pull {PUBLIC}" assert not any("local/c-0123456789ab" in script for script in sandbox.scripts if script.startswith("docker pull")) assert sandbox.scripts[-2].endswith("-t local/c-0123456789ab:v1 .") + + +@pytest.mark.asyncio +async def test_a_vm_installs_the_trust_dir_in_its_container_through_itself_and_cleans_up(tmp_path): + """The container is on the VM's Docker, not this machine's: the files go into the VM, then ``docker cp`` there.""" + (tmp_path / "ca.pem").write_bytes(b"ca") + (tmp_path / "ca-bundle.pem").write_bytes(b"bundle") + vm = _RecordingVmSandbox() + + await vm.copy_trust_dir_into_container(tmp_path, "agent-x", "/etc/agentenv") + + scripts = vm.scripts + staged = next(s for s in scripts if s.startswith("mkdir -p ")).removeprefix("mkdir -p ") + assert staged.startswith("/tmp/_trust_") and "agent-x" in staged + assert sum("base64" in s for s in scripts) == 2 # one write per file + assert any(s.startswith(f"docker cp {staged}/. agent-x:/etc/agentenv") for s in scripts) + assert scripts[-1] == f"rm -rf {staged}" + + +@pytest.mark.asyncio +async def test_start_trusting_a_container_on_a_vm_never_runs_docker_on_this_machine(tmp_path, monkeypatch): + import agent_env.providers.sandbox_providers.local_sandbox as ls + + monkeypatch.setattr(ls, "_copy_into_container", lambda *args: pytest.fail("docker cp ran on this machine")) + (tmp_path / "ca.pem").write_bytes(b"ca") + vm = _RecordingVmSandbox() + + await ls.start_trusting(vm, "agent-x", tmp_path) + + assert any(s.startswith("docker cp ") and s.endswith("agent-x:/etc/agentenv") for s in vm.scripts) + assert vm.scripts[-1] == "docker start agent-x > /dev/null" diff --git a/tst/unit/store/local_grant_server_test.py b/tst/unit/store/local_grant_server_test.py index d88de1a1..86b218c9 100644 --- a/tst/unit/store/local_grant_server_test.py +++ b/tst/unit/store/local_grant_server_test.py @@ -282,6 +282,33 @@ def test_grant_urls_name_the_advertised_host(tmp_path, monkeypatch): server.close() +@pytest.mark.skipif(sys.platform != "linux", reason="only Linux has the whole 127.0.0.0/8 on its loopback interface") +def test_the_platform_default_is_served_on_loopback_too_where_a_vm_on_this_machine_reaches_it(tmp_path, monkeypatch): + """A second loopback address stands in for the Linux Docker bridge address the default binds.""" + monkeypatch.setattr(server_module, "_servers", {}) + monkeypatch.setattr(server_module, "default_bind_host", lambda: "127.0.0.2") + store = LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="localhost") + try: + grant = store.issue_read_grant(store.put("k", b"both")) + assert httpx.get(grant.url).content == b"both" + assert httpx.get(grant.url.replace("localhost", "127.0.0.2")).content == b"both" + finally: + for server in server_module._servers.values(): + server.close() + + +def test_an_explicit_bind_host_is_served_as_given(tmp_path, monkeypatch): + monkeypatch.setattr(server_module, "_servers", {}) + monkeypatch.setattr(server_module, "default_bind_host", lambda: "127.0.0.2") + store = LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="127.0.0.1") + try: + store.issue_read_grant(store.put("k", b"v")) + assert [server.also_loopback for server in server_module._servers.values()] == [False] + finally: + for server in server_module._servers.values(): + server.close() + + def test_settings_are_checked_when_the_store_is_made(tmp_path): with pytest.raises(ValueError, match="IP address"): LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="localhost") diff --git a/tst/unit/store/test_object_transfer_grants.py b/tst/unit/store/test_object_transfer_grants.py index 144775a4..907fed93 100644 --- a/tst/unit/store/test_object_transfer_grants.py +++ b/tst/unit/store/test_object_transfer_grants.py @@ -4,18 +4,64 @@ import pytest +from agent_env.config import reset_config +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider from agent_env.store.object_store.local.store import LocalFilesystemObjectStore from agent_env.store.routing import LocalRunObjectStore from tst.util.granting_object_store import GrantingObjectStore -def test_local_store_grants_reach_only_local_sandboxes(tmp_path) -> None: +class _VmHere(SandboxProvider): + """A config-registered provider of VMs on this machine, whose networking forwards this machine's names to its + loopback, as SmolVM's does.""" + + ON_THIS_MACHINE = True + HOST_ADDRESS = "127.0.0.1" + + async def create_vm(self, **kwargs): # pragma: no cover - never provisioned here + raise NotImplementedError + + +@pytest.fixture +def vm_here(tmp_path, monkeypatch): + (tmp_path / "config.toml").write_text( + '[sandbox.providers.vm_here]\nimpl = "tst.unit.store.test_object_transfer_grants:_VmHere"\n' + ) + monkeypatch.setenv("AGENT_ENV_CONFIG", str(tmp_path / "config.toml")) + reset_config() + yield + reset_config() + + +def test_local_store_grants_reach_only_sandboxes_on_this_machine(tmp_path) -> None: store = LocalFilesystemObjectStore(str(tmp_path)) assert store.supports_transfer_grants assert store.grants_reach("local") assert not store.grants_reach("modal") assert not store.grants_reach(None) + assert not store.grants_reach("no-such-provider") + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "::1"]) +def test_a_grant_url_naming_this_machines_loopback_reaches_no_container_which_it_names_itself_to(tmp_path, host) -> None: + assert not LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host=host).grants_reach("local") + + +def test_a_container_on_this_machines_docker_reaches_the_server_wherever_it_binds(tmp_path) -> None: + assert LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="172.17.0.1").grants_reach("local") + assert LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="172.17.0.1").grants_reach("local") + + +def test_a_vm_on_this_machine_reaches_the_server_at_its_declared_loopback_address_by_name(tmp_path, vm_here) -> None: + """The platform default is served on loopback too; a bind host elsewhere, or a URL naming an address, which in the + VM is the VM's own, is not reached.""" + assert LocalFilesystemObjectStore(str(tmp_path)).grants_reach("vm_here") + assert LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="127.0.0.1").grants_reach("vm_here") + assert LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="host.docker.internal").grants_reach("vm_here") + assert not LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="172.17.0.1").grants_reach("vm_here") + assert not LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="172.17.0.1").grants_reach("vm_here") + assert not LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="localhost").grants_reach("vm_here") def test_local_store_grants_can_be_turned_off(tmp_path) -> None: From e3ae6090edc36d0cfc93f8d3cc9b83ec98bbb2d1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 22:41:48 -0700 Subject: [PATCH 2/4] test(store): fetch through the local CA in the loopback-bind test The test fetched its grant without trusting the local transfer CA, which the store fixture does for the others; the test is Linux-only, so it ran first in CI. Co-Authored-By: Claude Fable 5.1 --- tst/unit/store/local_grant_server_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tst/unit/store/local_grant_server_test.py b/tst/unit/store/local_grant_server_test.py index 86b218c9..a6445449 100644 --- a/tst/unit/store/local_grant_server_test.py +++ b/tst/unit/store/local_grant_server_test.py @@ -287,6 +287,7 @@ def test_the_platform_default_is_served_on_loopback_too_where_a_vm_on_this_machi """A second loopback address stands in for the Linux Docker bridge address the default binds.""" monkeypatch.setattr(server_module, "_servers", {}) monkeypatch.setattr(server_module, "default_bind_host", lambda: "127.0.0.2") + monkeypatch.setenv("SSL_CERT_FILE", str(local_ca().bundle_path)) store = LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="localhost") try: grant = store.issue_read_grant(store.put("k", b"both")) From f50ca1cb570e97f6cfdc2474463989e691273819 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 23:00:42 -0700 Subject: [PATCH 3/4] fix(store): a single bind that fails raises, and a VM's grants reach only an address the server serves With one bind address the retry loop ran once but re-raised only on the last of five attempts, so an unavailable explicit grant_bind_host lost its error and startup went on with no sockets. And grants_reach took the default bind to serve whatever loopback address a VM provider declared; the default serves 127.0.0.1, so another address needs the server bound to it. Co-Authored-By: Claude Fable 5.1 --- .../providers/sandbox_providers/sandbox_provider.py | 5 +++-- .../store/object_store/local/grant_server.py | 11 +++++++---- src/agent_env/store/object_store/local/store.py | 11 ++++++----- tst/unit/store/local_grant_server_test.py | 12 ++++++++++++ tst/unit/store/test_object_transfer_grants.py | 13 +++++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 058e9e14..2fc9149e 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -177,8 +177,9 @@ def from_config(cls, **config: Any) -> Self: ON_THIS_MACHINE: ClassVar[bool] = False # For a provider on this machine, where a request from its sandboxes to this machine's names arrives: None for # containers on this machine's Docker, at the address ``host.docker.internal`` maps to, where the local grant - # server listens by default; a loopback address for a VM here whose networking forwards this machine's names to - # it, which the server listens on too by default, and from which this machine's addresses by number aren't reached. + # server listens by default; for a VM here whose networking forwards this machine's names to an address of its + # own, that address: ``127.0.0.1``, which the server serves as well by default, or another the server is bound to. + # From such a VM this machine's addresses by number aren't reached. HOST_ADDRESS: ClassVar[str | None] = None # The sandbox modes it creates, derived for each provider class from what it implements (``__init_subclass__``): # SANDBOX_MODE_CONTAINER when it implements create_sandbox, SANDBOX_MODE_VM when it implements create_vm. diff --git a/src/agent_env/store/object_store/local/grant_server.py b/src/agent_env/store/object_store/local/grant_server.py index 37dfdb67..c99bf2f8 100644 --- a/src/agent_env/store/object_store/local/grant_server.py +++ b/src/agent_env/store/object_store/local/grant_server.py @@ -52,6 +52,8 @@ DEFAULT_ADVERTISE_HOST = "host.docker.internal" _START_TIMEOUT_SECONDS = 10 _BIND_ATTEMPTS = 5 # a second socket on one port can lose the port to another process in between +# Where the platform default is served as well: the address a VM on this machine reaches it at (``HOST_ADDRESS``). +DEFAULT_ALSO_SERVED = "127.0.0.1" _OPS_BY_METHOD = {"GET": "get", "HEAD": "get", "PUT": "put", "POST": "post"} _UPLOAD_KEY_FIELD = "key" # the form fields a policy upload names its key and file by (HttpPostPolicyGrant's path/file fields) _UPLOAD_FILE_FIELD = "file" @@ -86,7 +88,7 @@ def grant_server(bind_host: str | None, advertise_host: str | None) -> GrantServ resolved = bind_host or default_bind_host() # The platform default is served on loopback too, where a VM on this machine reaches it; an explicit bind # host is served as given. - key = (resolved, advertise_host or DEFAULT_ADVERTISE_HOST, bind_host is None and resolved != "127.0.0.1") + key = (resolved, advertise_host or DEFAULT_ADVERTISE_HOST, bind_host is None and resolved != DEFAULT_ALSO_SERVED) with _servers_lock: server = _servers.get(key) if server is None: @@ -202,10 +204,11 @@ def _start(self) -> int: with self._lock: if self._port is not None: return self._port - bind_hosts = [self.bind_host] + (["127.0.0.1"] if self.also_loopback else []) + bind_hosts = [self.bind_host] + ([DEFAULT_ALSO_SERVED] if self.also_loopback else []) sockets: list[socket.socket] = [] + attempts = _BIND_ATTEMPTS if len(bind_hosts) > 1 else 1 try: - for attempt in range(_BIND_ATTEMPTS if len(bind_hosts) > 1 else 1): + for attempt in range(attempts): try: for host in bind_hosts: # the first picks the port, the rest bind it family = socket.AF_INET6 if ipaddress.ip_address(host).version == 6 else socket.AF_INET @@ -215,7 +218,7 @@ def _start(self) -> int: break except OSError: _close_all(sockets) - if attempt == _BIND_ATTEMPTS - 1: + if attempt == attempts - 1: raise hosts = ["localhost", "host.docker.internal", "127.0.0.1", self.advertise_host] with contextlib.suppress(ValueError): # a name the CA cannot vouch for would void the certificate diff --git a/src/agent_env/store/object_store/local/store.py b/src/agent_env/store/object_store/local/store.py index 7a02ea75..19548d07 100644 --- a/src/agent_env/store/object_store/local/store.py +++ b/src/agent_env/store/object_store/local/store.py @@ -26,7 +26,7 @@ if os.name != "nt": import fcntl from agent_env.store.local_state import ensure_state_dir -from agent_env.store.object_store.local.grant_server import DEFAULT_ADVERTISE_HOST, grant_server +from agent_env.store.object_store.local.grant_server import DEFAULT_ADVERTISE_HOST, DEFAULT_ALSO_SERVED, grant_server from agent_env.store.object_store.local.tls import check_local_host from agent_env.store.object_store.local.tokens import Op from agent_env.store.object_store.object_store import ( @@ -196,9 +196,9 @@ def grants_reach(self, sandbox_type: str | None) -> bool: (its provider's ``ON_THIS_MACHINE``): when the server listens where the sandbox's requests arrive and the grant URL names this machine by a name the sandbox resolves: not its loopback by number or as ``localhost``, which inside any sandbox is the sandbox's own. A container on this machine's Docker reaches the address - ``host.docker.internal`` maps to, so any bind host. A VM here reaches the loopback address its provider - declares (``HOST_ADDRESS``), served by default or when bound there, and no address by number, which there is - the VM's own.""" + ``host.docker.internal`` maps to, so any bind host. A VM here reaches the address its provider declares + (``HOST_ADDRESS``), which the server serves when bound there, or by default when it is the address the default + serves as well, and no address by number, which there is the VM's own.""" from agent_env.providers.sandbox_providers.sandbox_provider import on_this_machine_provider # providers import this module provider = on_this_machine_provider(sandbox_type) @@ -209,7 +209,8 @@ def grants_reach(self, sandbox_type: str | None) -> bool: return False if provider.HOST_ADDRESS is None: return True - return self._grant_bind_host in (None, provider.HOST_ADDRESS) and not _is_address(advertised) + served = DEFAULT_ALSO_SERVED if self._grant_bind_host is None else self._grant_bind_host + return served == provider.HOST_ADDRESS and not _is_address(advertised) def issue_read_grant(self, object_url: str, *, expires_in: int | None = None) -> HttpGetGrant: expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in diff --git a/tst/unit/store/local_grant_server_test.py b/tst/unit/store/local_grant_server_test.py index a6445449..40eebe1a 100644 --- a/tst/unit/store/local_grant_server_test.py +++ b/tst/unit/store/local_grant_server_test.py @@ -15,6 +15,7 @@ from agent_env.a2a_agent.object_transfer import TransferCall, invoke_transfer from agent_env.config.paths import state_root from agent_env.store import LocalFilesystemObjectStore +from agent_env.store.base import GrantUnavailableError from agent_env.store.object_store.local import grant_server as server_module from agent_env.store.object_store.local.grant_server import default_bind_host, grant_server, unreachable_hint from agent_env.store.object_store.local.tls import local_ca @@ -310,6 +311,17 @@ def test_an_explicit_bind_host_is_served_as_given(tmp_path, monkeypatch): server.close() +def test_an_explicit_bind_host_this_machine_doesnt_have_reports_the_bind_error(tmp_path, monkeypatch): + monkeypatch.setattr(server_module, "_servers", {}) + store = LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="203.0.113.1") # TEST-NET-3: nobody's + try: + with pytest.raises(GrantUnavailableError, match="cannot listen on 203.0.113.1"): + store.issue_read_grant(store.put("k", b"v")) + finally: + for server in server_module._servers.values(): + server.close() + + def test_settings_are_checked_when_the_store_is_made(tmp_path): with pytest.raises(ValueError, match="IP address"): LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="localhost") diff --git a/tst/unit/store/test_object_transfer_grants.py b/tst/unit/store/test_object_transfer_grants.py index 907fed93..b3dba078 100644 --- a/tst/unit/store/test_object_transfer_grants.py +++ b/tst/unit/store/test_object_transfer_grants.py @@ -22,10 +22,17 @@ async def create_vm(self, **kwargs): # pragma: no cover - never provisioned her raise NotImplementedError +class _VmElsewhereOnLoopback(_VmHere): + """One that forwards this machine's names to a loopback address the default server doesn't serve.""" + + HOST_ADDRESS = "127.0.0.2" + + @pytest.fixture def vm_here(tmp_path, monkeypatch): (tmp_path / "config.toml").write_text( '[sandbox.providers.vm_here]\nimpl = "tst.unit.store.test_object_transfer_grants:_VmHere"\n' + '[sandbox.providers.vm_other]\nimpl = "tst.unit.store.test_object_transfer_grants:_VmElsewhereOnLoopback"\n' ) monkeypatch.setenv("AGENT_ENV_CONFIG", str(tmp_path / "config.toml")) reset_config() @@ -64,6 +71,12 @@ def test_a_vm_on_this_machine_reaches_the_server_at_its_declared_loopback_addres assert not LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="localhost").grants_reach("vm_here") +def test_a_vm_reaching_an_address_the_default_server_doesnt_serve_needs_the_server_bound_there(tmp_path, vm_here) -> None: + assert not LocalFilesystemObjectStore(str(tmp_path)).grants_reach("vm_other") + assert not LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="127.0.0.1").grants_reach("vm_other") + assert LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="127.0.0.2").grants_reach("vm_other") + + def test_local_store_grants_can_be_turned_off(tmp_path) -> None: assert not LocalFilesystemObjectStore(str(tmp_path), grants="off").supports_transfer_grants with pytest.raises(ValueError, match="grants must be one of"): From 1af98be0cf51cb3d0d8b79fd93c2552219092ea1 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Mon, 12 Oct 2026 23:14:35 -0700 Subject: [PATCH 4/4] refactor(store): the grant server takes its bind hosts; REACHES_HOST_AT names the declaration; grants_reach names its questions grant_server() now hands GrantServer the tuple of hosts to bind, computed once, instead of a flag it re-derives a list from, and the same-port bind with its retry lives in _bind_same_port, which raises a host's own bind failure at once and retries only a port lost between two binds. HOST_ADDRESS, which read as the host's address, is REACHES_HOST_AT. The local store's grants_reach reads as two cases with a named _serves predicate, and drops a branch for 0.0.0.0, which check_local_host already refuses as an advertised host. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- .../sandbox_providers/sandbox_provider.py | 14 ++--- .../store/object_store/local/grant_server.py | 55 +++++++++++-------- .../store/object_store/local/store.py | 32 +++++------ .../provider_capabilities_test.py | 6 +- tst/unit/store/local_grant_server_test.py | 2 +- tst/unit/store/test_object_transfer_grants.py | 4 +- 7 files changed, 59 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5a892e03..c204c595 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ and deserialized through a registry. | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, and more). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. A new backend must pass the conformance kits in `tst/store/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | -| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `tensorlake` (the `tensorlake` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine where their requests to it arrive, `HOST_ADDRESS`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | +| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `tensorlake` (the `tensorlake` extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store) and `EnvironmentGatewayProvider`, which renders a docker-compose for the gateway and its MCP servers inside the sandbox; `providers/env_state/` holds env-state providers (`local_postgres` built in). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `runner/` | The `[runner]` seam: `Runner.submit()` returns `(run_id, instance_id)`; `LocalRunner` is built in. | | `explorer/` | Optional local web UI: `agent-env up`, needs the `explorer` extra, binds loopback `:8234`. | diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 2fc9149e..7ea88a06 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -173,14 +173,14 @@ def from_config(cls, **config: Any) -> Self: # Whether its sandboxes run on this machine: an image only this machine holds (in a registry or object store on # it) reaches them, they reach this machine's loopback as ``host.docker.internal`` (resolved by Docker, or by the # provider's ``extra_hosts``), they trust the local transfer CA, and a gateway's infra envs are built here for - # them. They are containers on this machine's Docker, or VMs here (``HOST_ADDRESS``). + # them. They are containers on this machine's Docker, or VMs here (``REACHES_HOST_AT``). ON_THIS_MACHINE: ClassVar[bool] = False - # For a provider on this machine, where a request from its sandboxes to this machine's names arrives: None for - # containers on this machine's Docker, at the address ``host.docker.internal`` maps to, where the local grant - # server listens by default; for a VM here whose networking forwards this machine's names to an address of its - # own, that address: ``127.0.0.1``, which the server serves as well by default, or another the server is bound to. - # From such a VM this machine's addresses by number aren't reached. - HOST_ADDRESS: ClassVar[str | None] = None + # For a provider on this machine, the address its sandboxes' requests to this machine's names arrive at: None for + # containers on this machine's Docker, which arrive at the address ``host.docker.internal`` maps to, where the + # local grant server listens by default; for a VM here whose networking forwards this machine's names to an + # address of its own, that address: ``127.0.0.1``, which the server serves as well by default, or another the + # server is bound to. From such a VM, this machine's addresses by number aren't reached. + REACHES_HOST_AT: ClassVar[str | None] = None # The sandbox modes it creates, derived for each provider class from what it implements (``__init_subclass__``): # SANDBOX_MODE_CONTAINER when it implements create_sandbox, SANDBOX_MODE_VM when it implements create_vm. SUPPORTED_SANDBOX_MODES: ClassVar[frozenset[str]] = frozenset() diff --git a/src/agent_env/store/object_store/local/grant_server.py b/src/agent_env/store/object_store/local/grant_server.py index c99bf2f8..5d9028b4 100644 --- a/src/agent_env/store/object_store/local/grant_server.py +++ b/src/agent_env/store/object_store/local/grant_server.py @@ -52,7 +52,7 @@ DEFAULT_ADVERTISE_HOST = "host.docker.internal" _START_TIMEOUT_SECONDS = 10 _BIND_ATTEMPTS = 5 # a second socket on one port can lose the port to another process in between -# Where the platform default is served as well: the address a VM on this machine reaches it at (``HOST_ADDRESS``). +# Where the platform default is served as well: the address a VM on this machine arrives at (``REACHES_HOST_AT``). DEFAULT_ALSO_SERVED = "127.0.0.1" _OPS_BY_METHOD = {"GET": "get", "HEAD": "get", "PUT": "put", "POST": "post"} _UPLOAD_KEY_FIELD = "key" # the form fields a policy upload names its key and file by (HttpPostPolicyGrant's path/file fields) @@ -62,7 +62,7 @@ _MAX_PART_HEADER_BYTES = 16 * 1024 _DOWNLOAD_CHUNK_BYTES = 1024 * 1024 -_servers: dict[tuple[str, str, bool], GrantServer] = {} +_servers: dict[tuple[tuple[str, ...], str], GrantServer] = {} _servers_lock = threading.Lock() @@ -86,9 +86,10 @@ def grant_server(bind_host: str | None, advertise_host: str | None) -> GrantServ """This process's grant server for a bind and advertised host, created on first use; neither given means this platform's default bind address and ``host.docker.internal``.""" resolved = bind_host or default_bind_host() - # The platform default is served on loopback too, where a VM on this machine reaches it; an explicit bind - # host is served as given. - key = (resolved, advertise_host or DEFAULT_ADVERTISE_HOST, bind_host is None and resolved != DEFAULT_ALSO_SERVED) + # The platform default is served at DEFAULT_ALSO_SERVED too, where a VM on this machine arrives; an explicit + # bind host is served as given. + also = () if bind_host is not None or resolved == DEFAULT_ALSO_SERVED else (DEFAULT_ALSO_SERVED,) + key = ((resolved, *also), advertise_host or DEFAULT_ADVERTISE_HOST) with _servers_lock: server = _servers.get(key) if server is None: @@ -139,17 +140,36 @@ def default_bind_host() -> str: def _close_all(sockets: list[socket.socket]) -> None: for sock in sockets: sock.close() - sockets.clear() + + +def _bind_same_port(hosts: tuple[str, ...]) -> list[socket.socket]: + """Sockets bound to ``hosts`` on one free port: the first picks it, the rest take it. Another process can take + the port on a later host in between, which is retried; a host that can't be bound at all raises as it is.""" + attempts = _BIND_ATTEMPTS if len(hosts) > 1 else 1 + for attempt in range(attempts): + sockets: list[socket.socket] = [] + try: + for host in hosts: + family = socket.AF_INET6 if ipaddress.ip_address(host).version == 6 else socket.AF_INET + sockets.append(socket.socket(family, socket.SOCK_STREAM)) + sockets[-1].bind((host, sockets[0].getsockname()[1] if len(sockets) > 1 else 0)) + return sockets + except OSError: + lost_the_port = len(sockets) > 1 + _close_all(sockets) + if not lost_the_port or attempt == attempts - 1: + raise + raise AssertionError("unreachable") class GrantServer: """Serves the grants of the local stores registered with it, over HTTPS on one port. Started by the first grant it issues; it runs in a daemon thread for the rest of the process.""" - def __init__(self, bind_host: str, advertise_host: str, also_loopback: bool = False) -> None: - self.bind_host = bind_host + def __init__(self, bind_hosts: tuple[str, ...], advertise_host: str) -> None: + self.bind_hosts = bind_hosts + self.bind_host = bind_hosts[0] # the one configured or the platform default; any other is served as well self.advertise_host = advertise_host - self.also_loopback = also_loopback self._signer = GrantSigner() self._stores: dict[str, LocalFilesystemObjectStore] = {} self._lock = threading.Lock() @@ -204,22 +224,9 @@ def _start(self) -> int: with self._lock: if self._port is not None: return self._port - bind_hosts = [self.bind_host] + ([DEFAULT_ALSO_SERVED] if self.also_loopback else []) sockets: list[socket.socket] = [] - attempts = _BIND_ATTEMPTS if len(bind_hosts) > 1 else 1 try: - for attempt in range(attempts): - try: - for host in bind_hosts: # the first picks the port, the rest bind it - family = socket.AF_INET6 if ipaddress.ip_address(host).version == 6 else socket.AF_INET - sock = socket.socket(family, socket.SOCK_STREAM) - sockets.append(sock) - sock.bind((host, sockets[0].getsockname()[1] if len(sockets) > 1 else 0)) - break - except OSError: - _close_all(sockets) - if attempt == attempts - 1: - raise + sockets = _bind_same_port(self.bind_hosts) hosts = ["localhost", "host.docker.internal", "127.0.0.1", self.advertise_host] with contextlib.suppress(ValueError): # a name the CA cannot vouch for would void the certificate check_local_host(self.bind_host) @@ -255,7 +262,7 @@ def serve() -> None: time.sleep(0.01) self._uvicorn, self._thread = server, thread self._port = sockets[0].getsockname()[1] - logger.info("Local grant server listening on %s:%d", " and ".join(bind_hosts), self._port) + logger.info("Local grant server listening on %s:%d", " and ".join(self.bind_hosts), self._port) return self._port async def _handle(self, request: Request) -> Response: diff --git a/src/agent_env/store/object_store/local/store.py b/src/agent_env/store/object_store/local/store.py index 19548d07..c968a7c7 100644 --- a/src/agent_env/store/object_store/local/store.py +++ b/src/agent_env/store/object_store/local/store.py @@ -192,25 +192,23 @@ def get_object_key(self, object_url: str) -> str: return path.relative_to(root).as_posix() def grants_reach(self, sandbox_type: str | None) -> bool: - """Whether a sandbox of ``sandbox_type`` reaches this store's grant server, as only one on this machine does - (its provider's ``ON_THIS_MACHINE``): when the server listens where the sandbox's requests arrive and the grant - URL names this machine by a name the sandbox resolves: not its loopback by number or as ``localhost``, which - inside any sandbox is the sandbox's own. A container on this machine's Docker reaches the address - ``host.docker.internal`` maps to, so any bind host. A VM here reaches the address its provider declares - (``HOST_ADDRESS``), which the server serves when bound there, or by default when it is the address the default - serves as well, and no address by number, which there is the VM's own.""" + """Whether a sandbox of ``sandbox_type`` reaches this store's grant server: one on this machine (its provider's + ``ON_THIS_MACHINE``), at an address the server serves, by a grant URL naming this machine as it resolves it.""" from agent_env.providers.sandbox_providers.sandbox_provider import on_this_machine_provider # providers import this module provider = on_this_machine_provider(sandbox_type) if provider is None: return False advertised = self._grant_advertise_host or DEFAULT_ADVERTISE_HOST - if _is_loopback_literal(advertised): - return False - if provider.HOST_ADDRESS is None: - return True - served = DEFAULT_ALSO_SERVED if self._grant_bind_host is None else self._grant_bind_host - return served == provider.HOST_ADDRESS and not _is_address(advertised) + if provider.REACHES_HOST_AT is None: # a container on this machine's Docker: at host.docker.internal's address + return not _is_own_loopback(advertised) + # a VM here: only at the address it declares, and only by a name, as an address there is the VM's own + return self._serves(provider.REACHES_HOST_AT) and not _is_address(advertised) and advertised != "localhost" + + def _serves(self, address: str) -> bool: + """Whether the grant server listens at ``address``: where it's bound, or by default also at the one a VM on + this machine arrives at (``DEFAULT_ALSO_SERVED``).""" + return address == (DEFAULT_ALSO_SERVED if self._grant_bind_host is None else self._grant_bind_host) def issue_read_grant(self, object_url: str, *, expires_in: int | None = None) -> HttpGetGrant: expires_in = self.grant_lifetime_seconds if expires_in is None else expires_in @@ -455,8 +453,6 @@ def _is_address(host: str) -> bool: return True -def _is_loopback_literal(host: str) -> bool: - """Whether ``host`` names this machine from itself: ``localhost``, a loopback address or the unspecified one.""" - if host == "localhost": - return True - return _is_address(host) and (ipaddress.ip_address(host).is_loopback or ipaddress.ip_address(host).is_unspecified) +def _is_own_loopback(host: str) -> bool: + """Whether ``host`` is ``localhost`` or a loopback address, which inside a sandbox is the sandbox's own.""" + return host == "localhost" or (_is_address(host) and ipaddress.ip_address(host).is_loopback) diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index d5127312..3a8b51a9 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -69,7 +69,7 @@ async def create_vm(self, **_): def test_a_provider_that_declares_nothing_has_what_it_implements_and_loads_an_agents_image_on_a_vm(): - assert (_Vms.ON_THIS_MACHINE, _Vms.HOST_ADDRESS, _Vms.PRIVATE_NETWORK) == (False, None, False) + assert (_Vms.ON_THIS_MACHINE, _Vms.REACHES_HOST_AT, _Vms.PRIVATE_NETWORK) == (False, None, False) assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (LOADABLE, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" @@ -135,8 +135,8 @@ def test_the_provider_of_a_type_on_this_machine_is_read_from_the_registry_and_on assert on_this_machine_provider(sandbox_type) is expected -def test_every_built_in_providers_sandboxes_are_containers_on_this_machines_docker_or_elsewhere(): - assert all(cls.HOST_ADDRESS is None for cls in PROVIDER_CLASSES) +def test_every_built_in_providers_sandboxes_are_containers_on_this_machines_docker_or_elsewhere(): # none is a VM here + assert all(cls.REACHES_HOST_AT is None for cls in PROVIDER_CLASSES) def test_a_chain_is_its_providers_in_order_and_one_provider_is_itself(): diff --git a/tst/unit/store/local_grant_server_test.py b/tst/unit/store/local_grant_server_test.py index 40eebe1a..8748d390 100644 --- a/tst/unit/store/local_grant_server_test.py +++ b/tst/unit/store/local_grant_server_test.py @@ -305,7 +305,7 @@ def test_an_explicit_bind_host_is_served_as_given(tmp_path, monkeypatch): store = LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="127.0.0.1") try: store.issue_read_grant(store.put("k", b"v")) - assert [server.also_loopback for server in server_module._servers.values()] == [False] + assert [server.bind_hosts for server in server_module._servers.values()] == [("127.0.0.1",)] finally: for server in server_module._servers.values(): server.close() diff --git a/tst/unit/store/test_object_transfer_grants.py b/tst/unit/store/test_object_transfer_grants.py index b3dba078..b772b987 100644 --- a/tst/unit/store/test_object_transfer_grants.py +++ b/tst/unit/store/test_object_transfer_grants.py @@ -16,7 +16,7 @@ class _VmHere(SandboxProvider): loopback, as SmolVM's does.""" ON_THIS_MACHINE = True - HOST_ADDRESS = "127.0.0.1" + REACHES_HOST_AT = "127.0.0.1" async def create_vm(self, **kwargs): # pragma: no cover - never provisioned here raise NotImplementedError @@ -25,7 +25,7 @@ async def create_vm(self, **kwargs): # pragma: no cover - never provisioned her class _VmElsewhereOnLoopback(_VmHere): """One that forwards this machine's names to a loopback address the default server doesn't serve.""" - HOST_ADDRESS = "127.0.0.2" + REACHES_HOST_AT = "127.0.0.2" @pytest.fixture