From f263bf5c894f943060ff04a85d438c0a0dcd41f6 Mon Sep 17 00:00:00 2001 From: BinBin He Date: Sat, 10 Oct 2026 06:54:27 -0700 Subject: [PATCH 01/11] feat(sandbox): add SmolVM local sandbox provider --- README.md | 18 ++ pyproject.toml | 5 +- .../sandbox_providers/sandbox_provider.py | 1 + .../sandbox_providers/smol_vm/__init__.py | 6 + .../sandbox_providers/smol_vm/provider.py | 159 ++++++++++++++++++ .../sandbox_providers/smol_vm/sandbox.py | 91 ++++++++++ src/agent_env/store/object_store/local/tls.py | 2 +- .../smol_vm_provider_test.py | 79 +++++++++ tst/unit/store/local_grant_server_test.py | 12 ++ tst/unit/store/local_tls_test.py | 6 +- 10 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 src/agent_env/providers/sandbox_providers/smol_vm/__init__.py create mode 100644 src/agent_env/providers/sandbox_providers/smol_vm/provider.py create mode 100644 src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py create mode 100644 tst/unit/providers/sandbox_providers/smol_vm_provider_test.py diff --git a/README.md b/README.md index f79e41c7..dc20bea9 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,24 @@ scores = await episode.result() # every score the run recorded, by step id The scores are the ones `agent-env run` reports, so making a reward of them is yours. `trainer_agents=` names the agents you play when the task prompts several, `turn_timeout` is each turn's deadline in seconds (the prompt's own `timeout_seconds` otherwise), `episode.abandon(reason)` stops a run early, and `from_task` names any step an outside player can't stand behind. [Running tasks](https://www.agentenvframework.com/docs/tasks/running) covers the rest. +## Run agents in local microVMs + +Install `agentenv-framework[smol]` and select the `smol_vm` sandbox provider in +`.agentenv/config.toml`: + +```toml +[sandbox] +default = "smol_vm" +agent_default = "smol_vm" +``` + +Each agent and environment gets its own local SmolVM machine with a Docker daemon, +so workloads are isolated from the host Docker daemon. This needs a supported +Apple Silicon macOS or Linux host; on Linux, `/dev/kvm` must be available. AgentEnv can still +use its default local object and image stores: guest containers can fetch local +object grants, and the VM forwards pulls from the host's loopback registry. +Cloud image and object stores continue to work through the same provider. + ## Plugins A package of your own can add env types, providers, task steps and stores to agent-env, and `agent-env plugin` lists, checks, adds and removes installed plugins. The [plugin docs](https://www.agentenvframework.com/docs/plugins) show how. diff --git a/pyproject.toml b/pyproject.toml index 41a1d57f..fae652b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ tenki = [ "tenki[async]~=1.4.1", # AsyncClient/AsyncSandbox exec, stdin, ports and egress APIs validated against the 1.4 line ] dev = [ - "agentenv-framework[aws,explorer,gcp,sail,tensorlake,tenki]", # so the AWS, explorer, GCP backend, Sail, Tensorlake and Tenki provider tests run in CI + "agentenv-framework[aws,explorer,gcp,sail,smol,tensorlake,tenki]", # exercise optional providers and stores in CI "fastmcp>=3.4,<4", # the standalone-fastmcp mount path of the protocol; <4 keeps mcp 1.x "griffelib==2.3.0", # the plugin API check; exact, since what it reports as a break changes between releases "moto>=5.0.0", @@ -93,6 +93,9 @@ dev = [ "pytest-timeout>=2.3.0", "pytest-xdist>=3.0.0", ] +smol = [ + "smolmachines>=1.26.1", +] [project.scripts] agent-env = "agent_env.cli:cli" diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index 26539ea0..fbff3147 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -295,6 +295,7 @@ def effective_network_policy(cls, policy: Optional[NetworkPolicy]) -> NetworkPol "modal_vm": "agent_env.providers.sandbox_providers.modal_vm_sandbox:ModalVmSandboxProvider", "e2b": "agent_env.providers.sandbox_providers.e2b:E2BSandboxProvider", "sail_vm": "agent_env.providers.sandbox_providers.sail_vm.provider:SailVmSandboxProvider", + "smol_vm": "agent_env.providers.sandbox_providers.smol_vm.provider:SmolVmSandboxProvider", "tensorlake": "agent_env.providers.sandbox_providers.tensorlake.provider:TensorlakeSandboxProvider", "tenki": "agent_env.providers.sandbox_providers.tenki.provider:TenkiSandboxProvider", "local": "agent_env.providers.sandbox_providers.local_sandbox:LocalSandboxProvider", diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/__init__.py b/src/agent_env/providers/sandbox_providers/smol_vm/__init__.py new file mode 100644 index 00000000..69672cd8 --- /dev/null +++ b/src/agent_env/providers/sandbox_providers/smol_vm/__init__.py @@ -0,0 +1,6 @@ +"""Local SmolVM sandbox provider.""" + +from agent_env.providers.sandbox_providers.smol_vm.provider import SmolVmSandboxProvider +from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox + +__all__ = ["SmolVmSandbox", "SmolVmSandboxProvider"] diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py new file mode 100644 index 00000000..aa1dce30 --- /dev/null +++ b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py @@ -0,0 +1,159 @@ +"""Run AgentEnv workloads in local SmolVM machines.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import math +import shlex +import socket +import uuid +from urllib.parse import urlsplit +from typing import ClassVar + +from smol import ConnectOptions, Machine, MachineConfig, PortSpec, ResourceSpec + +from agent_env.attribution import Attribution +from agent_env.config import get_config +from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore +from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy +from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, local_grant_trust, start_trusting +from agent_env.providers.sandbox_providers.sandbox_provider import ( + Accepts, SandboxProvider, refuse_unenforceable_policy, +) +from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox, _host_url + +_DUMMY_PORT = 60832 + + +def _host_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class SmolVmSandboxProvider(SandboxProvider): + ON_THIS_MACHINE: ClassVar[bool] = True + CREATES_VMS: ClassVar[bool] = True + SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.LOADABLE + CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME + + @classmethod + def shares_network_with(cls, sandbox_type: str | None) -> bool: + return False + + @classmethod + def get_external_url(cls, url: str) -> str: + return _host_url(url) + + def url_from_sandbox(self, url: str) -> str: + return _host_url(url) + + async def create_sandbox(self, *, image_name: str, port: int, env: dict[str, str], + cpu: float = 1.0, memory: int = 8192, disk_size_gb: float = 10, + timeout: int = 7200, attribution: Attribution | None = None, + network_policy: NetworkPolicy | None = None) -> SmolVmSandbox: + return await self.create_container( + image_name=image_name, port=port, env=env, cpu=cpu, memory=memory, + disk_size_gb=disk_size_gb, timeout=timeout, attribution=attribution, + network_policy=network_policy, + ) + + async def create_vm(self, *, image: str | None = None, boot_mode: str | None = None, + cpu: float = 1.0, memory: int = 8192, disk_size_gb: float = 10, + timeout: int = 7200, exposed_ports: list[int] | None = None, + setup_for_gateway: bool = True, attribution: Attribution | None = None, + network_policy: NetworkPolicy | None = None) -> SmolVmSandbox: + refuse_unenforceable_policy(self, network_policy) + if image is not None or boot_mode is not None: + raise ValueError("smol_vm provisions its own Docker-capable guest; image and boot_mode overrides are unsupported") + if cpu < 1 or memory <= 0 or disk_size_gb <= 0: + raise ValueError("CPU, memory, and storage must be positive") + name = f"agentenv-{uuid.uuid4().hex[:12]}" + ports = {port: _host_port() for port in dict.fromkeys(exposed_ports or [])} + published = ports or {_DUMMY_PORT: _host_port()} + config = MachineConfig( + name=name, persistent=True, wait_for_ports=False, + resources=ResourceSpec(cpus=math.ceil(cpu), memory_mb=memory, storage_gb=math.ceil(disk_size_gb), network=True), + ports=[PortSpec(host=host, guest=guest) for guest, host in published.items()], + ) + machine = await asyncio.to_thread(Machine.create, config, ConnectOptions(target="local")) + sandbox = SmolVmSandbox(machine, ports) + try: + await self._setup_docker(sandbox) + await self._configure_host_access(sandbox) + await sandbox.persist_ports() + sandbox.network_policy = self.effective_network_policy(network_policy) + return sandbox + except BaseException: + await sandbox.terminate() + raise + + async def _setup_docker(self, sandbox: SmolVmSandbox) -> None: + code, stdout, stderr = await sandbox.exec_with_output( + "sh", "-c", "command -v bash >/dev/null && command -v docker >/dev/null && " + "command -v curl >/dev/null && command -v python3 >/dev/null && command -v unzip >/dev/null && command -v socat >/dev/null " + "|| apk add --no-cache docker docker-cli-compose bash curl python3 unzip socat", + ) + if code: + raise RuntimeError(f"Could not install guest Docker dependencies ({code}): {stdout[-500:]} {stderr[-500:]}") + await sandbox.exec_script(""" +set -e +mkdir -p /storage/docker /storage/containerd /var/lib/docker /var/lib/containerd +mountpoint -q /var/lib/docker || mount --bind /storage/docker /var/lib/docker +mountpoint -q /var/lib/containerd || mount --bind /storage/containerd /var/lib/containerd +if ! docker info >/dev/null 2>&1; then + rm -f /var/run/docker.pid /run/containerd/containerd.pid + dockerd --storage-driver=overlay2 >/tmp/agentenv-dockerd.log 2>&1 & + for i in $(seq 1 40); do docker info >/dev/null 2>&1 && exit 0; sleep 1; done + tail -80 /tmp/agentenv-dockerd.log + exit 1 +fi +""") + await self._ensure_local_registry_proxy(sandbox) + + async def _ensure_local_registry_proxy(self, sandbox: SmolVmSandbox) -> None: + store = get_config().get_image_store() + if not isinstance(store, LocalRegistryImageStore): + return + registry = urlsplit(f"//{store.registry_host}") + if registry.hostname not in ("localhost", "127.0.0.1"): + return + port = registry.port or 5000 + pid_file = f"/run/agentenv-registry-proxy-{port}.pid" + await sandbox.exec_script(f""" +if ! test -f {pid_file} || ! kill -0 "$(cat {pid_file})" 2>/dev/null; then + socat TCP-LISTEN:{port},bind=127.0.0.1,reuseaddr,fork TCP:host.smolvm.internal:{port} \ + >/tmp/agentenv-registry-proxy-{port}.log 2>&1 & + echo $! > {pid_file} +fi +sleep 0.1 +kill -0 "$(cat {pid_file})" +""") + + async def get_sandbox(self, sandbox_id: str) -> SmolVmSandbox: + sandbox = await SmolVmSandbox.reconnect(sandbox_id) + await self._setup_docker(sandbox) + await self._configure_host_access(sandbox) + sandbox.network_policy = self.effective_network_policy(None) + return sandbox + + async def _configure_host_access(self, sandbox: SmolVmSandbox) -> None: + code, stdout, stderr = await sandbox.exec_with_output("getent", "ahostsv4", "host.smolvm.internal") + if code or not stdout.strip(): + raise RuntimeError(f"Cannot resolve SmolVM host gateway: {stderr[-500:]}") + gateway = str(ipaddress.IPv4Address(stdout.split()[0])) + sandbox.extra_hosts = (f"host.docker.internal:{gateway}",) + + def _container_args(self, sandbox: SmolVmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> str: + args = super()._container_args(sandbox, image_name=image_name, port=port, env=env) + return " ".join(f"--add-host {shlex.quote(entry)}" for entry in sandbox.extra_hosts) + " " + args + + async def _start_container(self, sandbox: SmolVmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> None: + trust_dir = await asyncio.to_thread(local_grant_trust) + if trust_dir is None: + await super()._start_container(sandbox, image_name=image_name, port=port, env=env) + return + args = self._container_args(sandbox, image_name=image_name, port=port, env={**LOCAL_TRUST_ENV, **env}) + await sandbox.exec_script(f"docker create {args} > /dev/null") + await start_trusting(sandbox, sandbox.container_name, trust_dir) diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py new file mode 100644 index 00000000..cc38bc54 --- /dev/null +++ b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py @@ -0,0 +1,91 @@ +"""A local SmolVM machine with a Docker daemon, exposed through the VM sandbox contract.""" + +from __future__ import annotations + +import asyncio +import json +import shlex +from urllib.parse import urlsplit, urlunsplit + +from smol import ConnectOptions, Machine + +from agent_env.config import get_config +from agent_env.providers.sandbox_providers.sandbox import CURL_RETRY_FLAGS, VmSandbox +from agent_env.store.object_store.local.tls import local_ca +from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM + +_PORTS_FILE = "/storage/agentenv-ports.json" +_HOST_ALIAS = "host.smolvm.internal" + + +def _host_url(url: str) -> str: + parts = urlsplit(url) + if parts.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0", "::", "host.docker.internal"): + 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}")) + + +class SmolVmSandbox(VmSandbox): + type = "smol_vm" + ON_THIS_MACHINE = True + + def __init__(self, machine: Machine, port_map: dict[int, int]): + self._machine = machine + self._port_map = port_map + self.sandbox_id = machine.id + self.tunnel_urls = {port: machine.endpoint(port).http_url for port in port_map} + self.vnc_url = None + self.mode = SANDBOX_MODE_VM + self.network_policy = None + self.extra_hosts = () + + def url_from_sandbox(self, url: str) -> str: + return _host_url(url) + + async def terminate(self) -> None: + await asyncio.to_thread(self._machine.delete) + + async def exec_with_output(self, *args: str) -> tuple[int, str, str]: + command = list(args[1:] if args and args[0] == "sudo" else args) + result = await asyncio.to_thread(self._machine.exec, command) + return result.exit_code, result.stdout, result.stderr + + async def write_host_file(self, data: bytes, vm_path: str) -> None: + await asyncio.to_thread(self._machine.write_file, vm_path, data) + + async def _download_object_to_vm(self, object_url: str, vm_path: str) -> None: + store = get_config().get_object_store_at(object_url) + signed = await asyncio.to_thread(store.signed_get_url, object_url) + if signed is None: + await self._write_unsigned_object(store, object_url, vm_path) + return + rewritten = _host_url(signed) + local_https = rewritten != signed and urlsplit(signed).scheme == "https" + if local_https: + await self.write_host_file(local_ca().bundle_path.read_bytes(), "/etc/agentenv-ca-bundle.pem") + ca = "SSL_CERT_FILE=/etc/agentenv-ca-bundle.pem " if local_https else "" + await self.exec_script(f"{ca}curl -fsSL {CURL_RETRY_FLAGS} {shlex.quote(rewritten)} -o {shlex.quote(vm_path)}") + + async def write_file_from_url(self, url: str, destination_path: str) -> None: + vm_path = self._staging_path("url", destination_path) + rewritten = _host_url(url) + local_https = rewritten != url and urlsplit(url).scheme == "https" + try: + if local_https: + await self.write_host_file(local_ca().bundle_path.read_bytes(), "/etc/agentenv-ca-bundle.pem") + ca = "SSL_CERT_FILE=/etc/agentenv-ca-bundle.pem " if local_https else "" + await self.exec_script(f"{ca}curl -fsSL {CURL_RETRY_FLAGS} {shlex.quote(rewritten)} -o {shlex.quote(vm_path)}") + await self._copy_into_container(vm_path, destination_path) + finally: + await self._remove_vm_temp_file(vm_path) + + async def persist_ports(self) -> None: + await self.write_host_file(json.dumps(self._port_map).encode(), _PORTS_FILE) + + @classmethod + async def reconnect(cls, name: str) -> SmolVmSandbox: + machine = await asyncio.to_thread(Machine.connect, name, ConnectOptions(target="local", wait_for_ports=False)) + data = await asyncio.to_thread(machine.read_file, _PORTS_FILE) + return cls(machine, {int(port): int(host) for port, host in json.loads(data).items()}) diff --git a/src/agent_env/store/object_store/local/tls.py b/src/agent_env/store/object_store/local/tls.py index d3ae6d90..016159d5 100644 --- a/src/agent_env/store/object_store/local/tls.py +++ b/src/agent_env/store/object_store/local/tls.py @@ -32,7 +32,7 @@ from agent_env.store.local_state import ensure_state_dir # What the CA may vouch for: the names containers reach the host by, and loopback and private addresses. -LOCAL_NAMES = ("localhost", "host.docker.internal") +LOCAL_NAMES = ("localhost", "host.docker.internal", "host.smolvm.internal") LOCAL_NETWORKS = tuple( ipaddress.ip_network(n) for n in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7") diff --git a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py new file mode 100644 index 00000000..900fcc4f --- /dev/null +++ b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py @@ -0,0 +1,79 @@ +"""Local SmolVM lifecycle rules that must hold even when cloud credentials are present.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from agent_env.providers.sandbox_providers.smol_vm import provider as module +from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox, _host_url + + +@pytest.mark.asyncio +async def test_vm_creation_selects_local_and_can_publish_an_unstarted_application(monkeypatch): + created = [] + + class FakeMachine: + id = "agentenv-test" + + def endpoint(self, port): + return SimpleNamespace(http_url=f"http://127.0.0.1:44000") + + def write_file(self, path, data): + created.append((path, data)) + + def delete(self): + created.append(("deleted", None)) + + def create(config, conn): + assert conn.target == "local" + assert config.wait_for_ports is False + assert config.resources.network is True + assert [(p.host, p.guest) for p in config.ports] == [(44000, 8080)] + return FakeMachine() + + async def setup(self, sandbox): + sandbox.extra_hosts = ("host.docker.internal:100.96.0.1",) + + monkeypatch.setattr(module, "Machine", SimpleNamespace(create=create)) + monkeypatch.setattr(module, "_host_port", lambda: 44000) + monkeypatch.setattr(module.SmolVmSandboxProvider, "_setup_docker", setup) + monkeypatch.setattr(module.SmolVmSandboxProvider, "_configure_host_access", setup) + sandbox = await module.SmolVmSandboxProvider().create_vm(exposed_ports=[8080], memory=1024) + assert sandbox.tunnel_urls[8080] == "http://127.0.0.1:44000" + assert ("/storage/agentenv-ports.json", b'{"8080": 44000}') in created + await sandbox.terminate() + assert ("deleted", None) in created + + +@pytest.mark.asyncio +async def test_reconnect_uses_explicit_agent_only_readiness(monkeypatch): + options = [] + + class FakeMachine: + id = "agentenv-test" + + def read_file(self, path): + assert path == "/storage/agentenv-ports.json" + return json.dumps({"8080": 44000}).encode() + + def endpoint(self, port): + assert port == 8080 + return SimpleNamespace(http_url="http://127.0.0.1:44000") + + def connect(name, conn): + assert name == "agentenv-test" + options.append(conn) + return FakeMachine() + + monkeypatch.setattr("agent_env.providers.sandbox_providers.smol_vm.sandbox.Machine", SimpleNamespace(connect=connect)) + sandbox = await SmolVmSandbox.reconnect("agentenv-test") + assert sandbox.tunnel_urls == {8080: "http://127.0.0.1:44000"} + assert options[0].target == "local" and options[0].wait_for_ports is False + + +def test_local_urls_are_rewritten_for_vm_host(): + assert _host_url("http://127.0.0.1:5000/v2/") == "http://host.smolvm.internal:5000/v2/" + assert _host_url("https://example.org/path") == "https://example.org/path" diff --git a/tst/unit/store/local_grant_server_test.py b/tst/unit/store/local_grant_server_test.py index 8748d390..7968d95a 100644 --- a/tst/unit/store/local_grant_server_test.py +++ b/tst/unit/store/local_grant_server_test.py @@ -2,6 +2,7 @@ grant cases, and the bounds and refusals S3 would apply to the same requests.""" import os +import ssl import subprocess import sys import threading @@ -335,6 +336,17 @@ def test_the_root_defaults_to_the_state_root(): assert LocalFilesystemObjectStore().root == state_root() / "object_store" +def test_default_bridge_listener_also_serves_loopback_on_the_same_port(store, monkeypatch, tmp_path): + # A second loopback IP stands in for the Linux Docker bridge address. + monkeypatch.setattr(server_module, "default_bind_host", lambda: "127.0.0.2") + bridged = LocalFilesystemObjectStore(str(tmp_path / "bridged"), grant_bind_host="127.0.0.2", grant_advertise_host="localhost") + grant = bridged.issue_read_grant(bridged.put("k", b"both")) + with httpx.Client(verify=ssl.create_default_context(cafile=str(local_ca().bundle_path)), trust_env=False) as client: + assert client.get(grant.url).content == b"both" + assert client.get(grant.url.replace("localhost", "127.0.0.2")).content == b"both" + grant_server("127.0.0.2", "localhost").close() + + class TestDefaultBindHost: @pytest.fixture(autouse=True) def _fresh(self): diff --git a/tst/unit/store/local_tls_test.py b/tst/unit/store/local_tls_test.py index 9c557472..509e551f 100644 --- a/tst/unit/store/local_tls_test.py +++ b/tst/unit/store/local_tls_test.py @@ -38,10 +38,10 @@ def _handshake(server: ssl.SSLContext, *, cafile: Path, hostname: str) -> None: raise AssertionError("the handshake did not finish") -@pytest.mark.parametrize("hostname", ["host.docker.internal", "localhost", "127.0.0.1"]) +@pytest.mark.parametrize("hostname", ["host.docker.internal", "host.smolvm.internal", "localhost", "127.0.0.1"]) def test_a_server_certificate_verifies_for_the_names_it_was_made_for(hostname): ca = local_ca() - context = server_context(ca, ["localhost", "host.docker.internal", "127.0.0.1"]) + context = server_context(ca, ["localhost", "host.docker.internal", "host.smolvm.internal", "127.0.0.1"]) _handshake(context, cafile=ca.cert_path, hostname=hostname) _handshake(context, cafile=ca.bundle_path, hostname=hostname) @@ -132,7 +132,7 @@ def test_an_unreadable_ca_is_replaced(): @pytest.mark.parametrize("host", [ - "localhost", "host.docker.internal", "a.localhost", "127.0.0.1", "172.17.0.1", "192.168.5.2", "10.0.0.8", + "localhost", "host.docker.internal", "host.smolvm.internal", "a.localhost", "127.0.0.1", "172.17.0.1", "192.168.5.2", "10.0.0.8", "::1", "fd00::1", ]) def test_local_hosts_are_accepted(host): From 7ed405a0aaf0f66759b6e30b6ce49f4cd8042cbf Mon Sep 17 00:00:00 2001 From: BinBin He Date: Sat, 10 Oct 2026 06:56:56 -0700 Subject: [PATCH 02/11] fix(sandbox): preserve lazy imports and clean up cancelled VMs --- .../sandbox_providers/smol_vm/__init__.py | 7 +---- .../sandbox_providers/smol_vm/provider.py | 15 ++++++++++- .../network_policy_stamping_test.py | 12 +++++++++ .../smol_vm_provider_test.py | 27 +++++++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/__init__.py b/src/agent_env/providers/sandbox_providers/smol_vm/__init__.py index 69672cd8..d4b6e275 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/__init__.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/__init__.py @@ -1,6 +1 @@ -"""Local SmolVM sandbox provider.""" - -from agent_env.providers.sandbox_providers.smol_vm.provider import SmolVmSandboxProvider -from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox - -__all__ = ["SmolVmSandbox", "SmolVmSandboxProvider"] +"""Local SmolVM sandbox provider (imported only when the optional extra is selected).""" diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py index aa1dce30..379ceedd 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py @@ -71,13 +71,26 @@ async def create_vm(self, *, image: str | None = None, boot_mode: str | None = N raise ValueError("CPU, memory, and storage must be positive") name = f"agentenv-{uuid.uuid4().hex[:12]}" ports = {port: _host_port() for port in dict.fromkeys(exposed_ports or [])} + # Even a portless VM needs a published mapping to select routed virtio-net for nested Docker. published = ports or {_DUMMY_PORT: _host_port()} config = MachineConfig( name=name, persistent=True, wait_for_ports=False, resources=ResourceSpec(cpus=math.ceil(cpu), memory_mb=memory, storage_gb=math.ceil(disk_size_gb), network=True), ports=[PortSpec(host=host, guest=guest) for guest, host in published.items()], ) - machine = await asyncio.to_thread(Machine.create, config, ConnectOptions(target="local")) + creation = asyncio.create_task(asyncio.to_thread(Machine.create, config, ConnectOptions(target="local"))) + try: + machine = await asyncio.shield(creation) + except asyncio.CancelledError: + # The native create call keeps running after its awaiting task is cancelled. + # Wait for it and delete the result so cancellation cannot orphan a VM. + try: + created = await creation + except Exception: + pass + else: + await asyncio.to_thread(created.delete) + raise sandbox = SmolVmSandbox(machine, ports) try: await self._setup_docker(sandbox) diff --git a/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py b/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py index 963ac2f9..dc436b7a 100644 --- a/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py +++ b/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py @@ -19,6 +19,8 @@ from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm import _sdk as sail_sdk from agent_env.providers.sandbox_providers.sail_vm.provider import SailVmSandboxProvider +from agent_env.providers.sandbox_providers.smol_vm import provider as smol_module +from agent_env.providers.sandbox_providers.smol_vm.provider import SmolVmSandboxProvider from agent_env.providers.sandbox_providers.sandbox import Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import _BUILTIN_SANDBOX_PROVIDERS from agent_env.providers.sandbox_providers.tenki.provider import TenkiSandboxProvider @@ -107,6 +109,15 @@ async def _tenki() -> Sandbox: client.create = AsyncMock(return_value=session) provider = TenkiSandboxProvider(api_key="test-key", client_cls=MagicMock(return_value=client)) return await provider.create_vm(exposed_ports=[], setup_for_gateway=False) +async def _smol_vm() -> Sandbox: + machine = MagicMock(id="agentenv-test") + with ( + patch.object(smol_module, "Machine", SimpleNamespace(create=lambda config, conn: machine)), + patch.object(smol_module, "_host_port", return_value=44112), + patch.object(SmolVmSandboxProvider, "_setup_docker", new_callable=AsyncMock), + patch.object(SmolVmSandboxProvider, "_configure_host_access", new_callable=AsyncMock), + ): + return await SmolVmSandboxProvider().create_vm(exposed_ports=[]) FACTORIES = { @@ -116,6 +127,7 @@ async def _tenki() -> Sandbox: "sail_vm": _sail, "tensorlake": _tensorlake, "tenki": _tenki, + "smol_vm": _smol_vm, "local": _local, } diff --git a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py index 900fcc4f..dd9082f4 100644 --- a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py +++ b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py @@ -2,7 +2,9 @@ from __future__ import annotations +import asyncio import json +import threading from types import SimpleNamespace import pytest @@ -48,6 +50,31 @@ async def setup(self, sandbox): assert ("deleted", None) in created +@pytest.mark.asyncio +async def test_cancellation_during_native_creation_reclaims_vm(monkeypatch): + started, release = threading.Event(), threading.Event() + deleted = [] + + class FakeMachine: + def delete(self): + deleted.append(True) + + def create(config, conn): + started.set() + assert release.wait(timeout=10) + return FakeMachine() + + monkeypatch.setattr(module, "Machine", SimpleNamespace(create=create)) + monkeypatch.setattr(module, "_host_port", lambda: 44000) + task = asyncio.create_task(module.SmolVmSandboxProvider().create_vm(memory=1024)) + assert await asyncio.to_thread(started.wait, 5) + task.cancel() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + assert deleted == [True] + + @pytest.mark.asyncio async def test_reconnect_uses_explicit_agent_only_readiness(monkeypatch): options = [] From 68f88b75e6723722be6684ba623283ed952d6ffe Mon Sep 17 00:00:00 2001 From: BinBin He Date: Sat, 10 Oct 2026 07:02:25 -0700 Subject: [PATCH 03/11] perf(sandbox): stream local image artifacts over scoped grants --- .../sandbox_providers/smol_vm/sandbox.py | 6 ++- .../smol_vm_provider_test.py | 39 +++++++++++++++++++ tst/unit/store/test_object_transfer_grants.py | 6 +++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py index cc38bc54..e19ba00d 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py @@ -11,6 +11,7 @@ from agent_env.config import get_config from agent_env.providers.sandbox_providers.sandbox import CURL_RETRY_FLAGS, VmSandbox +from agent_env.store.object_store.local.store import LocalFilesystemObjectStore from agent_env.store.object_store.local.tls import local_ca from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM @@ -58,11 +59,14 @@ async def write_host_file(self, data: bytes, vm_path: str) -> None: async def _download_object_to_vm(self, object_url: str, vm_path: str) -> None: store = get_config().get_object_store_at(object_url) signed = await asyncio.to_thread(store.signed_get_url, object_url) + local_grant = isinstance(store, LocalFilesystemObjectStore) and store.supports_transfer_grants and store.grants_reach(self.type) + if signed is None and local_grant: + signed = (await asyncio.to_thread(store.issue_read_grant, object_url, expires_in=3600)).url if signed is None: await self._write_unsigned_object(store, object_url, vm_path) return rewritten = _host_url(signed) - local_https = rewritten != signed and urlsplit(signed).scheme == "https" + local_https = local_grant or (rewritten != signed and urlsplit(signed).scheme == "https") if local_https: await self.write_host_file(local_ca().bundle_path.read_bytes(), "/etc/agentenv-ca-bundle.pem") ca = "SSL_CERT_FILE=/etc/agentenv-ca-bundle.pem " if local_https else "" diff --git a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py index dd9082f4..56c87d10 100644 --- a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py +++ b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py @@ -5,12 +5,15 @@ import asyncio import json import threading +from unittest.mock import AsyncMock, MagicMock from types import SimpleNamespace import pytest from agent_env.providers.sandbox_providers.smol_vm import provider as module +from agent_env.providers.sandbox_providers.smol_vm import sandbox as sandbox_module from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox, _host_url +from agent_env.store.object_store.local.store import LocalFilesystemObjectStore @pytest.mark.asyncio @@ -104,3 +107,39 @@ def connect(name, conn): def test_local_urls_are_rewritten_for_vm_host(): assert _host_url("http://127.0.0.1:5000/v2/") == "http://host.smolvm.internal:5000/v2/" assert _host_url("https://example.org/path") == "https://example.org/path" + + +@pytest.mark.asyncio +async def test_local_object_download_uses_a_scoped_https_grant(monkeypatch, tmp_path): + store = LocalFilesystemObjectStore(str(tmp_path)) + object_url = store.put("image.tar.gz", b"archive") + issued = MagicMock(return_value=SimpleNamespace(url="https://host.docker.internal:1234/v1/grants/token")) + monkeypatch.setattr(store, "issue_read_grant", issued) + monkeypatch.setattr(sandbox_module, "get_config", lambda: SimpleNamespace(get_object_store_at=lambda url: store)) + machine = MagicMock(id="agentenv-test") + sandbox = SmolVmSandbox(machine, {}) + sandbox.write_host_file = AsyncMock() + sandbox.exec_script = AsyncMock() + sandbox._write_unsigned_object = AsyncMock() + + await sandbox._download_object_to_vm(object_url, "/storage/image.tar.gz") + + issued.assert_called_once_with(object_url, expires_in=3600) + sandbox._write_unsigned_object.assert_not_called() + sandbox.write_host_file.assert_awaited_once() + command = sandbox.exec_script.await_args.args[0] + assert "SSL_CERT_FILE=/etc/agentenv-ca-bundle.pem" in command + assert "https://host.smolvm.internal:1234/v1/grants/token" in command + assert "-o /storage/image.tar.gz" in command + + +@pytest.mark.asyncio +async def test_unreachable_local_grant_falls_back_to_object_push(monkeypatch, tmp_path): + store = LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="172.17.0.1") + object_url = store.put("image.tar.gz", b"archive") + monkeypatch.setattr(sandbox_module, "get_config", lambda: SimpleNamespace(get_object_store_at=lambda url: store)) + sandbox = SmolVmSandbox(MagicMock(id="agentenv-test"), {}) + sandbox._write_unsigned_object = AsyncMock() + + await sandbox._download_object_to_vm(object_url, "/storage/image.tar.gz") + sandbox._write_unsigned_object.assert_awaited_once_with(store, object_url, "/storage/image.tar.gz") diff --git a/tst/unit/store/test_object_transfer_grants.py b/tst/unit/store/test_object_transfer_grants.py index b772b987..5792883f 100644 --- a/tst/unit/store/test_object_transfer_grants.py +++ b/tst/unit/store/test_object_transfer_grants.py @@ -77,6 +77,12 @@ def test_a_vm_reaching_an_address_the_default_server_doesnt_serve_needs_the_serv assert LocalFilesystemObjectStore(str(tmp_path), grant_bind_host="127.0.0.2").grants_reach("vm_other") +def test_custom_private_advertised_host_is_not_claimed_reachable_from_smol_vm(tmp_path) -> None: + store = LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="172.17.0.1") + assert store.grants_reach("local") + assert not store.grants_reach("smol_vm") + + 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 a4ac55ef46a843e02c439a58d6942480c46d9a98 Mon Sep 17 00:00:00 2001 From: BinBin He Date: Sat, 10 Oct 2026 07:10:08 -0700 Subject: [PATCH 04/11] test(sandbox): preserve live SmolVM gateway validation --- tst/manual/smol_vm_gateway_check.py | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tst/manual/smol_vm_gateway_check.py diff --git a/tst/manual/smol_vm_gateway_check.py b/tst/manual/smol_vm_gateway_check.py new file mode 100644 index 00000000..ec91bc0b --- /dev/null +++ b/tst/manual/smol_vm_gateway_check.py @@ -0,0 +1,41 @@ +"""Run explicitly with ``pytest tst/manual/smol_vm_gateway_check.py`` on a KVM host with Docker and Smol installed.""" + +import uuid + +import pytest +from agentenv_protocol import client as protocol_v1 + +from agent_env.env import legacy_protocol +from agent_env.env.env import DeployedGatewayEnv, Env +from agent_env.env.envs.mcp_server import MCPServerEnv +from agent_env.env.envs.multi_env import MultiEnv +from tst.integration.env.envs.multi_env_local_test import _items_universe +from tst.integration.env.envs.server_env_local_test import _put_items_image +from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs, local_stack # noqa: F401 + + +@pytest.mark.asyncio +async def test_gateway_restores_and_serves_data_on_smol_vm(local_stack): + _seed_bootstrap_envs() + uid = uuid.uuid4().hex[:8] + items = MCPServerEnv.put( + id=f"smol-items-{uid}", + environment_name="items", + docker_image_artifact=_put_items_image(f"smol-items-img-{uid}"), + ) + env = MultiEnv.put(id=f"smol-multi-{uid}", mcp_server_envs=[items], name="smol-suite") + + try: + deployed = await env.deploy(sandbox_type="smol_vm", ttl_seconds=900, disk_size_gb=16, cpu=2, memory_mb=4096) + assert isinstance(deployed, DeployedGatewayEnv) + assert deployed.sandbox_type == "smol_vm" + assert deployed.gateway_url.startswith("http://127.0.0.1:") + + restored = await Env.from_instance_id(deployed.instance_id) + await restored.load_environment_universe_artifact(_items_universe(uid)) + base_url = await legacy_protocol.v1_base_url(deployed, deployed.gateway_url, "items") + payload = (await protocol_v1.get_data(base_url)).model_dump()["parts"][0]["data"] + assert payload == {"items": ["snap-x", "snap-y"]} + finally: + if env._sandbox is not None: + await env.close() From e88402467c6bcc438d83baabf3a8518c945a7c98 Mon Sep 17 00:00:00 2001 From: BinBin He Date: Sat, 10 Oct 2026 07:15:20 -0700 Subject: [PATCH 05/11] test(sandbox): validate agent workloads across SmolVM machines --- tst/manual/smol_vm_agent_check.py | 40 +++++++++++++++++++++++++ tst/manual/smol_vm_gateway_check.py | 45 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tst/manual/smol_vm_agent_check.py diff --git a/tst/manual/smol_vm_agent_check.py b/tst/manual/smol_vm_agent_check.py new file mode 100644 index 00000000..cfc6f4e7 --- /dev/null +++ b/tst/manual/smol_vm_agent_check.py @@ -0,0 +1,40 @@ +"""Run explicitly with ``pytest tst/manual/smol_vm_agent_check.py`` on a KVM host with Docker and Smol installed.""" + +import uuid + +import httpx +import pytest + +from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT +from agent_env.task.teardown import teardown_run +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep +from tst.integration.store.local_parallel_deploy_test import local_stack # noqa: F401 +from tst.util.a2a_test_agent import put_test_agent + + +@pytest.mark.asyncio +async def test_agent_answers_from_smol_vm_and_cleans_up(local_stack): + uid = uuid.uuid4().hex[:8] + agent = put_test_agent(f"smol-agent-{uid}") + context = TaskStepContext(instance_id=f"smol-agent-run-{uid}") + + try: + await DeploySandboxTaskStep( + id="box", version=None, sandbox_name="box", sandbox_mode="vm", sandbox_type="smol_vm", + exposed_ports=[DEFAULT_A2A_PORT], cpu=2, memory_mb=4096, disk_size_gb=16, + ).execute(context) + await DeployAgentTaskStep( + id="agent", version=None, env_ids=[], a2a_agent_id=agent.id, agent_name="solver", sandbox_name="box", + ).execute(context) + + assert len(context.deployed_agents) == 1 + url = context.deployed_agents[0].a2a_url + assert url == context.deployed_sandboxes[0].tunnel_urls[str(DEFAULT_A2A_PORT)] + async with httpx.AsyncClient() as client: + response = await client.get(f"{url}/.well-known/agent.json", timeout=30) + assert response.status_code == 200 + finally: + report = await teardown_run(context) + assert not report.still_up diff --git a/tst/manual/smol_vm_gateway_check.py b/tst/manual/smol_vm_gateway_check.py index ec91bc0b..7b8730d2 100644 --- a/tst/manual/smol_vm_gateway_check.py +++ b/tst/manual/smol_vm_gateway_check.py @@ -1,17 +1,25 @@ """Run explicitly with ``pytest tst/manual/smol_vm_gateway_check.py`` on a KVM host with Docker and Smol installed.""" +import json import uuid import pytest from agentenv_protocol import client as protocol_v1 +from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT from agent_env.env import legacy_protocol from agent_env.env.env import DeployedGatewayEnv, Env from agent_env.env.envs.mcp_server import MCPServerEnv from agent_env.env.envs.multi_env import MultiEnv +from agent_env.providers.sandbox_providers.smol_vm.provider import SmolVmSandboxProvider +from agent_env.task.teardown import teardown_run +from agent_env.task_step.context import TaskStepContext +from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep +from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from tst.integration.env.envs.multi_env_local_test import _items_universe from tst.integration.env.envs.server_env_local_test import _put_items_image from tst.integration.store.local_parallel_deploy_test import _seed_bootstrap_envs, local_stack # noqa: F401 +from tst.util.a2a_test_agent import put_test_agent @pytest.mark.asyncio @@ -25,6 +33,8 @@ async def test_gateway_restores_and_serves_data_on_smol_vm(local_stack): ) env = MultiEnv.put(id=f"smol-multi-{uid}", mcp_server_envs=[items], name="smol-suite") + context = TaskStepContext(instance_id=f"smol-env-agent-run-{uid}") + try: deployed = await env.deploy(sandbox_type="smol_vm", ttl_seconds=900, disk_size_gb=16, cpu=2, memory_mb=4096) assert isinstance(deployed, DeployedGatewayEnv) @@ -36,6 +46,41 @@ async def test_gateway_restores_and_serves_data_on_smol_vm(local_stack): base_url = await legacy_protocol.v1_base_url(deployed, deployed.gateway_url, "items") payload = (await protocol_v1.get_data(base_url)).model_dump()["parts"][0]["data"] assert payload == {"items": ["snap-x", "snap-y"]} + + context.deployed_envs.append(deployed) + agent = put_test_agent(f"smol-agent-{uid}") + await DeploySandboxTaskStep( + id="box", version=None, sandbox_name="box", sandbox_mode="vm", sandbox_type="smol_vm", + exposed_ports=[DEFAULT_A2A_PORT], cpu=2, memory_mb=4096, disk_size_gb=16, + ).execute(context) + await DeployAgentTaskStep( + id="agent", version=None, env_ids=[env.id], a2a_agent_id=agent.id, + agent_name="solver", sandbox_name="box", + ).execute(context) + + agent_vm = await SmolVmSandboxProvider().get_sandbox(context.deployed_sandboxes[0].sandbox_id) + code, names, stderr = await agent_vm.exec_with_output("docker", "ps", "--format", "{{.Names}}") + assert code == 0, stderr + assert len(names.splitlines()) == 1 + reachable = SmolVmSandboxProvider.get_external_url(deployed.gateway_url) + code, status, stderr = await agent_vm.exec_with_output( + "docker", "exec", names.strip(), "python", "-c", + "import sys; from urllib.request import urlopen; print(urlopen(sys.argv[1], timeout=20).status)", + f"{reachable}/.well-known/agent-env.json", + ) + assert code == 0, stderr + assert status.strip() == "200" + + code, data, stderr = await agent_vm.exec_with_output( + "docker", "exec", names.strip(), "python", "-c", + "import asyncio, sys; from agentenv_protocol import client; " + "print(asyncio.run(client.get_data(sys.argv[1])).model_dump_json())", + SmolVmSandboxProvider.get_external_url(base_url), + ) + assert code == 0, stderr + assert json.loads(data)["parts"][0]["data"] == {"items": ["snap-x", "snap-y"]} finally: + report = await teardown_run(context) if env._sandbox is not None: await env.close() + assert not report.still_up From c823a57a3c30e28c067d52238c38adc5b65a8d2c Mon Sep 17 00:00:00 2001 From: BinBin He Date: Sun, 11 Oct 2026 20:32:30 -0700 Subject: [PATCH 06/11] chore(smol): require published smolmachines 1.27.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fae652b2..516db124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,7 +94,7 @@ dev = [ "pytest-xdist>=3.0.0", ] smol = [ - "smolmachines>=1.26.1", + "smolmachines>=1.27.0", ] [project.scripts] From c310597f298fe89e730a056fe746512778f981cc Mon Sep 17 00:00:00 2001 From: BinBin He Date: Mon, 12 Oct 2026 14:57:35 -0700 Subject: [PATCH 07/11] fix(sandbox): address SmolVM review findings --- .../sandbox_providers/smol_vm/provider.py | 32 ++++++++------ .../sandbox_providers/smol_vm/sandbox.py | 6 ++- tst/manual/smol_vm_agent_check.py | 34 ++++++++++++++- .../smol_vm_provider_test.py | 43 +++++++++++++++++++ tst/unit/store/test_object_transfer_grants.py | 8 ++++ 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py index 379ceedd..4c3060ff 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py @@ -16,6 +16,7 @@ from agent_env.attribution import Attribution from agent_env.config import get_config from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore +from agent_env.store.routing import LocalRunImageStore from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, local_grant_trust, start_trusting from agent_env.providers.sandbox_providers.sandbox_provider import ( @@ -33,6 +34,8 @@ def _host_port() -> int: class SmolVmSandboxProvider(SandboxProvider): + _DOCKER_READY_ATTEMPTS: ClassVar[int] = 40 + _LOCAL_REGISTRY_DEFAULT_PORT: ClassVar[int] = 5000 ON_THIS_MACHINE: ClassVar[bool] = True CREATES_VMS: ClassVar[bool] = True SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.LOADABLE @@ -53,10 +56,9 @@ async def create_sandbox(self, *, image_name: str, port: int, env: dict[str, str cpu: float = 1.0, memory: int = 8192, disk_size_gb: float = 10, timeout: int = 7200, attribution: Attribution | None = None, network_policy: NetworkPolicy | None = None) -> SmolVmSandbox: - return await self.create_container( - image_name=image_name, port=port, env=env, cpu=cpu, memory=memory, - disk_size_gb=disk_size_gb, timeout=timeout, attribution=attribution, - network_policy=network_policy, + return await self.create_vm( + exposed_ports=[port], cpu=cpu, memory=memory, disk_size_gb=disk_size_gb, + timeout=timeout, attribution=attribution, network_policy=network_policy, ) async def create_vm(self, *, image: str | None = None, boot_mode: str | None = None, @@ -110,7 +112,7 @@ async def _setup_docker(self, sandbox: SmolVmSandbox) -> None: ) if code: raise RuntimeError(f"Could not install guest Docker dependencies ({code}): {stdout[-500:]} {stderr[-500:]}") - await sandbox.exec_script(""" + await sandbox.exec_script(f""" set -e mkdir -p /storage/docker /storage/containerd /var/lib/docker /var/lib/containerd mountpoint -q /var/lib/docker || mount --bind /storage/docker /var/lib/docker @@ -118,7 +120,7 @@ async def _setup_docker(self, sandbox: SmolVmSandbox) -> None: if ! docker info >/dev/null 2>&1; then rm -f /var/run/docker.pid /run/containerd/containerd.pid dockerd --storage-driver=overlay2 >/tmp/agentenv-dockerd.log 2>&1 & - for i in $(seq 1 40); do docker info >/dev/null 2>&1 && exit 0; sleep 1; done + for i in $(seq 1 {self._DOCKER_READY_ATTEMPTS}); do docker info >/dev/null 2>&1 && exit 0; sleep 1; done tail -80 /tmp/agentenv-dockerd.log exit 1 fi @@ -127,14 +129,16 @@ async def _setup_docker(self, sandbox: SmolVmSandbox) -> None: async def _ensure_local_registry_proxy(self, sandbox: SmolVmSandbox) -> None: store = get_config().get_image_store() - if not isinstance(store, LocalRegistryImageStore): - return - registry = urlsplit(f"//{store.registry_host}") - if registry.hostname not in ("localhost", "127.0.0.1"): - return - port = registry.port or 5000 - pid_file = f"/run/agentenv-registry-proxy-{port}.pid" - await sandbox.exec_script(f""" + stores = (store.configured, store.local) if isinstance(store, LocalRunImageStore) else (store,) + ports = set() + for candidate in stores: + if isinstance(candidate, LocalRegistryImageStore): + registry = urlsplit(f"//{candidate.registry_host}") + if registry.hostname in ("localhost", "127.0.0.1"): + ports.add(registry.port or self._LOCAL_REGISTRY_DEFAULT_PORT) + for port in sorted(ports): + pid_file = f"/run/agentenv-registry-proxy-{port}.pid" + await sandbox.exec_script(f""" if ! test -f {pid_file} || ! kill -0 "$(cat {pid_file})" 2>/dev/null; then socat TCP-LISTEN:{port},bind=127.0.0.1,reuseaddr,fork TCP:host.smolvm.internal:{port} \ >/tmp/agentenv-registry-proxy-{port}.log 2>&1 & diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py index e19ba00d..557169ff 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py @@ -5,6 +5,7 @@ import asyncio import json import shlex +from typing import ClassVar from urllib.parse import urlsplit, urlunsplit from smol import ConnectOptions, Machine @@ -29,6 +30,7 @@ def _host_url(url: str) -> str: class SmolVmSandbox(VmSandbox): + _READ_GRANT_LIFETIME_SECONDS: ClassVar[int] = 3600 type = "smol_vm" ON_THIS_MACHINE = True @@ -61,7 +63,9 @@ async def _download_object_to_vm(self, object_url: str, vm_path: str) -> None: signed = await asyncio.to_thread(store.signed_get_url, object_url) local_grant = isinstance(store, LocalFilesystemObjectStore) and store.supports_transfer_grants and store.grants_reach(self.type) if signed is None and local_grant: - signed = (await asyncio.to_thread(store.issue_read_grant, object_url, expires_in=3600)).url + signed = (await asyncio.to_thread( + store.issue_read_grant, object_url, expires_in=self._READ_GRANT_LIFETIME_SECONDS, + )).url if signed is None: await self._write_unsigned_object(store, object_url, vm_path) return diff --git a/tst/manual/smol_vm_agent_check.py b/tst/manual/smol_vm_agent_check.py index cfc6f4e7..c83bc720 100644 --- a/tst/manual/smol_vm_agent_check.py +++ b/tst/manual/smol_vm_agent_check.py @@ -6,12 +6,15 @@ import pytest from agent_env.a2a_agent.a2a_agent import DEFAULT_A2A_PORT +from agent_env.config import get_config, set_image_store +from agent_env.providers.sandbox_providers.smol_vm.provider import SmolVmSandboxProvider +from agent_env.store.routing import LocalRunImageStore from agent_env.task.teardown import teardown_run from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.deploy_agent import DeployAgentTaskStep from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep from tst.integration.store.local_parallel_deploy_test import local_stack # noqa: F401 -from tst.util.a2a_test_agent import put_test_agent +from tst.util.a2a_test_agent import build_test_agent_image, put_test_agent @pytest.mark.asyncio @@ -38,3 +41,32 @@ async def test_agent_answers_from_smol_vm_and_cleans_up(local_stack): finally: report = await teardown_run(context) assert not report.still_up + + +@pytest.mark.asyncio +async def test_agent_deploys_into_its_own_smol_vm(local_stack): + agent = put_test_agent(f"smol-own-vm-{uuid.uuid4().hex[:8]}") + + try: + deployed = await agent.deploy(sandbox_type="smol_vm", cpu=2, memory=4096, disk_size_gb=16) + assert deployed.sandbox_type == "smol_vm" + async with httpx.AsyncClient() as client: + response = await client.get(f"{deployed.a2a_url}/.well-known/agent.json", timeout=30) + assert response.status_code == 200 + finally: + await agent.close() + + +@pytest.mark.asyncio +async def test_local_run_registry_image_pulls_inside_smol_vm(local_stack): + artifact = build_test_agent_image(f"smol-local-run-{uuid.uuid4().hex[:8]}") + registry = get_config().get_image_store() + set_image_store(LocalRunImageStore(registry, registry)) + sandbox = await SmolVmSandboxProvider().create_vm(cpu=2, memory=4096, disk_size_gb=16) + + try: + await sandbox.pull_images([artifact.image_name]) + code, _, stderr = await sandbox.exec_with_output("docker", "image", "inspect", artifact.image_name) + assert code == 0, stderr + finally: + await sandbox.terminate() diff --git a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py index 56c87d10..1c3af69f 100644 --- a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py +++ b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py @@ -13,7 +13,37 @@ from agent_env.providers.sandbox_providers.smol_vm import provider as module from agent_env.providers.sandbox_providers.smol_vm import sandbox as sandbox_module from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox, _host_url +from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore from agent_env.store.object_store.local.store import LocalFilesystemObjectStore +from agent_env.store.routing import LocalRunImageStore + + +@pytest.mark.asyncio +async def test_agent_sandbox_returns_vm_to_load_saved_or_context_only_images(monkeypatch): + provider = module.SmolVmSandboxProvider() + create_vm = AsyncMock(return_value=SimpleNamespace(mode="vm")) + monkeypatch.setattr(provider, "create_vm", create_vm) + + sandbox = await provider.create_sandbox(image_name="local-image", port=8080, env={"A2A_PORT": "8080"}) + + assert sandbox.mode == "vm" + assert create_vm.await_args.kwargs["exposed_ports"] == [8080] + + +@pytest.mark.asyncio +async def test_local_run_proxies_its_underlying_loopback_registries(monkeypatch): + store = LocalRunImageStore( + LocalRegistryImageStore("localhost:5001"), LocalRegistryImageStore("localhost:5000"), + ) + monkeypatch.setattr(module, "get_config", lambda: SimpleNamespace(get_image_store=lambda: store)) + sandbox = SimpleNamespace(exec_script=AsyncMock()) + + await module.SmolVmSandboxProvider()._ensure_local_registry_proxy(sandbox) + + commands = [call.args[0] for call in sandbox.exec_script.await_args_list] + assert len(commands) == 2 + assert "TCP-LISTEN:5000" in commands[0] + assert "TCP-LISTEN:5001" in commands[1] @pytest.mark.asyncio @@ -143,3 +173,16 @@ async def test_unreachable_local_grant_falls_back_to_object_push(monkeypatch, tm await sandbox._download_object_to_vm(object_url, "/storage/image.tar.gz") sandbox._write_unsigned_object.assert_awaited_once_with(store, object_url, "/storage/image.tar.gz") + + +@pytest.mark.asyncio +async def test_loopback_advertised_grant_uses_object_push(monkeypatch, tmp_path): + store = LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host="localhost") + object_url = store.put("image.tar.gz", b"archive") + monkeypatch.setattr(sandbox_module, "get_config", lambda: SimpleNamespace(get_object_store_at=lambda url: store)) + sandbox = SmolVmSandbox(MagicMock(id="agentenv-test"), {}) + sandbox._write_unsigned_object = AsyncMock() + + await sandbox._download_object_to_vm(object_url, "/storage/image.tar.gz") + + sandbox._write_unsigned_object.assert_awaited_once_with(store, object_url, "/storage/image.tar.gz") diff --git a/tst/unit/store/test_object_transfer_grants.py b/tst/unit/store/test_object_transfer_grants.py index 5792883f..0050addd 100644 --- a/tst/unit/store/test_object_transfer_grants.py +++ b/tst/unit/store/test_object_transfer_grants.py @@ -83,6 +83,14 @@ def test_custom_private_advertised_host_is_not_claimed_reachable_from_smol_vm(tm assert not store.grants_reach("smol_vm") +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1"]) +def test_loopback_grant_url_is_not_claimed_reachable_from_agent_container(tmp_path, host) -> None: + store = LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host=host) + + assert store.grants_reach("local") + assert not store.grants_reach("smol_vm") + + 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 df1c92e65467b5e98bbee143e78d9f03217b8b62 Mon Sep 17 00:00:00 2001 From: BinBin He Date: Mon, 12 Oct 2026 15:06:53 -0700 Subject: [PATCH 08/11] fix(sandbox): start container-mode workloads on VM providers --- .../chained_sandbox_provider.py | 15 ++++++++-- .../task_step/task_steps/deploy_sandbox.py | 2 +- tst/manual/smol_vm_agent_check.py | 21 ++++++++++++++ .../task_step/deploy_sandbox_image_test.py | 29 +++++++++++++++---- .../deploy_sandbox_network_policy_test.py | 2 +- .../task_step/test_deploy_ttl_from_budget.py | 4 +-- 6 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py index 5fadf549..53a7a0f3 100644 --- a/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/chained_sandbox_provider.py @@ -66,6 +66,17 @@ async def create_sandbox( attribution: Attribution | None = None, **kwargs, ) -> Sandbox: + return await self._create("create_sandbox", attribution=attribution, **kwargs) + + async def create_container( + self, + *, + attribution: Attribution | None = None, + **kwargs, + ) -> Sandbox: + return await self._create("create_container", attribution=attribution, **kwargs) + + async def _create(self, method: str, *, attribution: Attribution | None = None, **kwargs) -> Sandbox: sandbox_providers = self.filter_sandbox_providers(self._providers, kwargs.get("network_policy")) image = self._images.get(kwargs.get("image_name")) errors: list[tuple[str, Exception]] = [] @@ -76,9 +87,9 @@ async def create_sandbox( # outside the deadline, which bounds creating the sandbox, not a build await p.prepare_image(image, attribution=attribution) async with asyncio.timeout(_PROVISION_DEADLINE_SECONDS): - sandbox = await p.create_sandbox(attribution=attribution, **kwargs) + sandbox = await getattr(p, method)(attribution=attribution, **kwargs) except Exception as e: - logger.warning(f"{name}.create_sandbox failed: {e}; trying next provider") + logger.warning(f"{name}.{method} failed: {e}; trying next provider") errors.append((name, e)) continue # A VM refuses an image it can't get or run, and a container runs only what its provider's containers do; diff --git a/src/agent_env/task_step/task_steps/deploy_sandbox.py b/src/agent_env/task_step/task_steps/deploy_sandbox.py index f0d8cd37..581e15b3 100644 --- a/src/agent_env/task_step/task_steps/deploy_sandbox.py +++ b/src/agent_env/task_step/task_steps/deploy_sandbox.py @@ -152,7 +152,7 @@ async def execute(self, context: TaskStepContext) -> TaskStepContext: if self.image is None or self.port is None: raise ValueError("container-mode sandbox requires `image` and `port`") provider = skip_refusing(provider, self._pull_refusal, what=f"sandbox '{self.sandbox_name}'") - sandbox = await provider.create_sandbox( + sandbox = await provider.create_container( image_name=self.image, port=self.port, env=self.env_vars or {}, diff --git a/tst/manual/smol_vm_agent_check.py b/tst/manual/smol_vm_agent_check.py index c83bc720..9909d467 100644 --- a/tst/manual/smol_vm_agent_check.py +++ b/tst/manual/smol_vm_agent_check.py @@ -70,3 +70,24 @@ async def test_local_run_registry_image_pulls_inside_smol_vm(local_stack): assert code == 0, stderr finally: await sandbox.terminate() + + +@pytest.mark.asyncio +async def test_container_mode_starts_image_with_environment_inside_smol_vm(local_stack): + agent = put_test_agent(f"smol-container-{uuid.uuid4().hex[:8]}") + context = TaskStepContext(instance_id=f"smol-container-run-{uuid.uuid4().hex[:8]}") + step = DeploySandboxTaskStep( + id="box", version=None, sandbox_name="box", sandbox_mode="container", sandbox_type="smol_vm", + image=agent.docker_image_artifact.image_name, port=DEFAULT_A2A_PORT, + env_vars={**agent.default_env_vars, "A2A_PORT": str(DEFAULT_A2A_PORT)}, + cpu=2, memory_mb=4096, disk_size_gb=16, + ) + + try: + await step.execute(context) + assert context.deployed_sandboxes[0].sandbox_mode == "container" + url = context.deployed_sandboxes[0].tunnel_urls[str(DEFAULT_A2A_PORT)] + assert (await agent._wait_for_agent_card(url))["name"] + finally: + report = await teardown_run(context) + assert not report.still_up diff --git a/tst/unit/task_step/deploy_sandbox_image_test.py b/tst/unit/task_step/deploy_sandbox_image_test.py index bbc038ea..1354cbf1 100644 --- a/tst/unit/task_step/deploy_sandbox_image_test.py +++ b/tst/unit/task_step/deploy_sandbox_image_test.py @@ -17,7 +17,7 @@ def _provider(on_this_machine: bool): sandbox.tunnel_urls, sandbox.vnc_url, sandbox.network_policy = {}, None, NetworkPolicy() provider = AsyncMock(ON_THIS_MACHINE=on_this_machine) provider.links = (provider,) - provider.create_sandbox.return_value = sandbox + provider.create_container.return_value = sandbox return provider @@ -31,13 +31,13 @@ async def test_a_container_sandbox_pulls_an_image_from_this_machines_registry_on with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", return_value=provider): if on_this_machine: await step.execute(TaskStepContext()) - provider.create_sandbox.assert_awaited_once() + provider.create_container.assert_awaited_once() else: with pytest.raises(ValueError, match="Can't deploy sandbox 'box' with AsyncMock, whose sandboxes pull it by " "name: localhost:5000/box:v1 is in a registry on this machine, which a " "sandbox elsewhere can't pull from"): await step.execute(TaskStepContext()) - provider.create_sandbox.assert_not_awaited() + provider.create_container.assert_not_awaited() @pytest.mark.asyncio @@ -50,6 +50,25 @@ async def test_a_chain_pulls_an_image_from_this_machines_registry_on_its_provide return_value=ChainedSandboxProvider([elsewhere, here])): await step.execute(TaskStepContext()) - elsewhere.create_sandbox.assert_not_awaited() - here.create_sandbox.assert_awaited_once() + elsewhere.create_container.assert_not_awaited() + here.create_container.assert_awaited_once() + +@pytest.mark.asyncio +async def test_a_container_deploy_falls_back_when_the_first_provider_fails(): + step = DeploySandboxTaskStep( + id="s", version=1, sandbox_name="box", sandbox_mode="container", + image="localhost:5000/box:v1", port=80, env_vars={"MODE": "test"}, + ) + first, second = _provider(on_this_machine=True), _provider(on_this_machine=True) + first.create_container.side_effect = RuntimeError("unavailable") + context = TaskStepContext() + + with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", + return_value=ChainedSandboxProvider([first, second])): + await step.execute(context) + + first.create_container.assert_awaited_once() + second.create_container.assert_awaited_once() + assert second.create_container.await_args.kwargs["env"] == {"MODE": "test"} + assert context.deployed_sandboxes[0].sandbox_mode == "container" diff --git a/tst/unit/task_step/deploy_sandbox_network_policy_test.py b/tst/unit/task_step/deploy_sandbox_network_policy_test.py index da4b7b61..82c051d4 100644 --- a/tst/unit/task_step/deploy_sandbox_network_policy_test.py +++ b/tst/unit/task_step/deploy_sandbox_network_policy_test.py @@ -46,7 +46,7 @@ def test_an_unknown_mode_is_rejected_when_the_task_is_built(): @pytest.mark.asyncio @pytest.mark.parametrize("mode, create_call, extra", [ ("vm", "create_vm", {}), - ("container", "create_sandbox", {"port": 8000}), # container mode requires image + port + ("container", "create_container", {"port": 8000}), # container mode requires image + port ]) async def test_the_policy_reaches_both_create_paths(mode, create_call, extra): step = _step(sandbox_mode=mode, image="img", network_policy=ALLOWLIST, **extra) diff --git a/tst/unit/task_step/test_deploy_ttl_from_budget.py b/tst/unit/task_step/test_deploy_ttl_from_budget.py index 74f062c5..bd1e60eb 100644 --- a/tst/unit/task_step/test_deploy_ttl_from_budget.py +++ b/tst/unit/task_step/test_deploy_ttl_from_budget.py @@ -55,7 +55,7 @@ async def test_deploy_sandbox_uses_budget_override(): async def test_deploy_sandbox_container_mode_uses_budget_override(): sb = MagicMock(sandbox_id="s1", mode="container", type="remote", tunnel_urls={}, vnc_url=None) provider = MagicMock() - provider.create_sandbox = AsyncMock(return_value=sb) + provider.create_container = AsyncMock(return_value=sb) step = DeploySandboxTaskStep( id="d", version=None, sandbox_name="sbx", sandbox_mode="container", image="img", port=8080, ttl_seconds=1800, @@ -63,7 +63,7 @@ async def test_deploy_sandbox_container_mode_uses_budget_override(): ctx = TaskStepContext(metadata={"user_overrides": {"ttl_seconds": 30600}}) with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", return_value=provider): await step.execute(ctx) - assert provider.create_sandbox.await_args.kwargs["timeout"] == 30600 + assert provider.create_container.await_args.kwargs["timeout"] == 30600 @pytest.mark.asyncio From d32101015d27ab0339e8a503c8b2c7b58030493e Mon Sep 17 00:00:00 2001 From: BinBin He Date: Mon, 12 Oct 2026 17:38:57 -0700 Subject: [PATCH 09/11] fix(sandbox): preserve custom container provider deployments --- .../sandbox_providers/sandbox_provider.py | 11 +++++- .../task_step/deploy_sandbox_image_test.py | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index fbff3147..522886ae 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -108,12 +108,19 @@ async def create_container( Caller still owns readiness polling for the container's own endpoints. ``private_network`` puts it on a network this provider's other such containers share, at its ``private_host``. - Default implementation: create_vm, authenticate Docker to ECR if applicable, docker pull, - docker run. Backends that bake the image in at sandbox-creation time (Modal) override. + Default implementation: a container-only backend uses create_sandbox; a VM backend creates + a VM, pulls the image, and starts it. Backends that bake the image in at creation override. """ if private_network: raise ValueError(f"{type(self).__name__} runs each container in a VM of its own, so it can't put " "containers on a private network they share") + if (type(self).create_vm is SandboxProvider.create_vm + and type(self).create_sandbox is not SandboxProvider.create_sandbox): + return await self.create_sandbox( + image_name=image_name, port=port, env=env, cpu=cpu, memory=memory, + disk_size_gb=disk_size_gb, timeout=timeout, attribution=attribution, + network_policy=network_policy, + ) sandbox = await self.create_vm( cpu=cpu, memory=memory, disk_size_gb=disk_size_gb, exposed_ports=[port], timeout=timeout, diff --git a/tst/unit/task_step/deploy_sandbox_image_test.py b/tst/unit/task_step/deploy_sandbox_image_test.py index 1354cbf1..52ab0441 100644 --- a/tst/unit/task_step/deploy_sandbox_image_test.py +++ b/tst/unit/task_step/deploy_sandbox_image_test.py @@ -1,12 +1,14 @@ """A container-mode deploy_sandbox runs its image by name, so a provider whose sandboxes are elsewhere can't pull one from this machine's registry; one on this machine can, and a chain skips the rest for it.""" +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy +from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider from agent_env.task_step.context import TaskStepContext from agent_env.task_step.task_steps.deploy_sandbox import DeploySandboxTaskStep @@ -21,6 +23,42 @@ def _provider(on_this_machine: bool): return provider +class _CustomContainerProvider(SandboxProvider): + ON_THIS_MACHINE = True + + def __init__(self): + self.calls = [] + + async def create_sandbox(self, **kwargs): + self.calls.append(kwargs) + return SimpleNamespace( + sandbox_id="custom-1", mode="container", type="custom", tunnel_urls={}, + vnc_url=None, network_policy=NetworkPolicy(), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("chained", [False, True], ids=["direct", "fallback-chain"]) +async def test_custom_container_only_provider_still_deploys(chained): + step = DeploySandboxTaskStep( + id="s", version=1, sandbox_name="box", sandbox_mode="container", + image="example.com/box:v1", port=80, env_vars={"MODE": "test"}, + ) + custom = _CustomContainerProvider() + if chained: + first = _provider(on_this_machine=True) + first.create_container.side_effect = RuntimeError("unavailable") + provider = ChainedSandboxProvider([first, custom]) + else: + provider = custom + + with patch("agent_env.providers.sandbox_providers.sandbox_provider.get_sandbox_provider", return_value=provider): + context = await step.execute(TaskStepContext()) + + assert custom.calls[0]["env"] == {"MODE": "test"} + assert context.deployed_sandboxes[0].sandbox_mode == "container" + + @pytest.mark.asyncio @pytest.mark.parametrize("on_this_machine", [False, True], ids=["elsewhere", "on-this-machine"]) async def test_a_container_sandbox_pulls_an_image_from_this_machines_registry_only_on_this_machine(on_this_machine): From 6a982e4e6b37d87ab9655d3008bd7b50b2ee17e5 Mon Sep 17 00:00:00 2001 From: BinBin He Date: Wed, 14 Oct 2026 00:47:52 -0700 Subject: [PATCH 10/11] fix(smol): align host grants and guest platform with upstream --- README.md | 4 +- .../sandbox_providers/smol_vm/provider.py | 19 +++++-- .../sandbox_providers/smol_vm/sandbox.py | 34 +++++------ src/agent_env/store/object_store/local/tls.py | 2 +- .../network_policy_stamping_test.py | 1 + .../smol_vm_provider_test.py | 57 +++++++++++++++++-- tst/unit/store/local_grant_server_test.py | 12 ---- tst/unit/store/local_tls_test.py | 8 +-- tst/unit/store/test_object_transfer_grants.py | 3 +- uv.lock | 27 +++++++-- 10 files changed, 115 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index dc20bea9..967991d6 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,9 @@ agent_default = "smol_vm" Each agent and environment gets its own local SmolVM machine with a Docker daemon, so workloads are isolated from the host Docker daemon. This needs a supported -Apple Silicon macOS or Linux host; on Linux, `/dev/kvm` must be available. AgentEnv can still +Apple Silicon macOS or Linux host; on Linux, `/dev/kvm` must be available. On Apple Silicon, +build images for `linux/arm64` (or provide their build contexts so SmolVM can rebuild them); +this provider does not install amd64 emulation in the guest. AgentEnv can still use its default local object and image stores: guest containers can fetch local object grants, and the VM forwards pulls from the host's loopback registry. Cloud image and object stores continue to work through the same provider. diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py index 4c3060ff..07606b5e 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py @@ -18,11 +18,11 @@ from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore from agent_env.store.routing import LocalRunImageStore from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy -from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, local_grant_trust, start_trusting +from agent_env.providers.sandbox_providers.local_sandbox import LOCAL_TRUST_ENV, LocalSandboxProvider, local_grant_trust, start_trusting from agent_env.providers.sandbox_providers.sandbox_provider import ( Accepts, SandboxProvider, refuse_unenforceable_policy, ) -from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox, _host_url +from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox _DUMMY_PORT = 60832 @@ -37,6 +37,7 @@ class SmolVmSandboxProvider(SandboxProvider): _DOCKER_READY_ATTEMPTS: ClassVar[int] = 40 _LOCAL_REGISTRY_DEFAULT_PORT: ClassVar[int] = 5000 ON_THIS_MACHINE: ClassVar[bool] = True + REACHES_HOST_AT: ClassVar[str] = "127.0.0.1" CREATES_VMS: ClassVar[bool] = True SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.LOADABLE CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME @@ -47,10 +48,10 @@ def shares_network_with(cls, sandbox_type: str | None) -> bool: @classmethod def get_external_url(cls, url: str) -> str: - return _host_url(url) + return LocalSandboxProvider.get_external_url(url) def url_from_sandbox(self, url: str) -> str: - return _host_url(url) + return LocalSandboxProvider.get_external_url(url) async def create_sandbox(self, *, image_name: str, port: int, env: dict[str, str], cpu: float = 1.0, memory: int = 8192, disk_size_gb: float = 10, @@ -76,7 +77,7 @@ async def create_vm(self, *, image: str | None = None, boot_mode: str | None = N # Even a portless VM needs a published mapping to select routed virtio-net for nested Docker. published = ports or {_DUMMY_PORT: _host_port()} config = MachineConfig( - name=name, persistent=True, wait_for_ports=False, + name=name, persistent=True, wait_for_ports=False, ttl_seconds=timeout, resources=ResourceSpec(cpus=math.ceil(cpu), memory_mb=memory, storage_gb=math.ceil(disk_size_gb), network=True), ports=[PortSpec(host=host, guest=guest) for guest, host in published.items()], ) @@ -96,6 +97,7 @@ async def create_vm(self, *, image: str | None = None, boot_mode: str | None = N sandbox = SmolVmSandbox(machine, ports) try: await self._setup_docker(sandbox) + await sandbox.detect_platform() await self._configure_host_access(sandbox) await sandbox.persist_ports() sandbox.network_policy = self.effective_network_policy(network_policy) @@ -160,6 +162,13 @@ async def _configure_host_access(self, sandbox: SmolVmSandbox) -> None: if code or not stdout.strip(): raise RuntimeError(f"Cannot resolve SmolVM host gateway: {stderr[-500:]}") gateway = str(ipaddress.IPv4Address(stdout.split()[0])) + # VM-level downloads use the same host name as the containers and local grants. + # Keep the entry current across reconnects without changing the host's trust CA. + await sandbox.exec_script( + "sed -i -e 's/[[:space:]]host\\.docker\\.internal\\([[:space:]]\\|$\\)/\\1/g' " + "-e '/^[[:space:]]*[[:xdigit:].:]\\+[[:space:]]*$/d' /etc/hosts && " + f"printf '%s\\n' {shlex.quote(gateway + ' host.docker.internal')} >> /etc/hosts" + ) sandbox.extra_hosts = (f"host.docker.internal:{gateway}",) def _container_args(self, sandbox: SmolVmSandbox, *, image_name: str, port: int, env: dict[str, str]) -> str: diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py index 557169ff..92afdba2 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py @@ -6,27 +6,19 @@ import json import shlex from typing import ClassVar -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import urlsplit from smol import ConnectOptions, Machine from agent_env.config import get_config from agent_env.providers.sandbox_providers.sandbox import CURL_RETRY_FLAGS, VmSandbox +from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider from agent_env.store.object_store.local.store import LocalFilesystemObjectStore from agent_env.store.object_store.local.tls import local_ca +from agent_env.utils.docker_build import normalize_platform from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_VM _PORTS_FILE = "/storage/agentenv-ports.json" -_HOST_ALIAS = "host.smolvm.internal" - - -def _host_url(url: str) -> str: - parts = urlsplit(url) - if parts.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0", "::", "host.docker.internal"): - 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}")) class SmolVmSandbox(VmSandbox): @@ -45,7 +37,13 @@ def __init__(self, machine: Machine, port_map: dict[int, int]): self.extra_hosts = () def url_from_sandbox(self, url: str) -> str: - return _host_url(url) + return LocalSandboxProvider.get_external_url(url) + + async def detect_platform(self) -> None: + code, stdout, stderr = await self.exec_with_output("uname", "-m") + if code or not stdout.strip(): + raise RuntimeError(f"Cannot determine SmolVM guest architecture: {stderr[-500:]}") + self.platform = normalize_platform(f"linux/{stdout.strip()}") async def terminate(self) -> None: await asyncio.to_thread(self._machine.delete) @@ -69,8 +67,8 @@ async def _download_object_to_vm(self, object_url: str, vm_path: str) -> None: if signed is None: await self._write_unsigned_object(store, object_url, vm_path) return - rewritten = _host_url(signed) - local_https = local_grant or (rewritten != signed and urlsplit(signed).scheme == "https") + rewritten = LocalSandboxProvider.get_external_url(signed) + local_https = local_grant or (urlsplit(rewritten).hostname == "host.docker.internal" and urlsplit(rewritten).scheme == "https") if local_https: await self.write_host_file(local_ca().bundle_path.read_bytes(), "/etc/agentenv-ca-bundle.pem") ca = "SSL_CERT_FILE=/etc/agentenv-ca-bundle.pem " if local_https else "" @@ -78,8 +76,8 @@ async def _download_object_to_vm(self, object_url: str, vm_path: str) -> None: async def write_file_from_url(self, url: str, destination_path: str) -> None: vm_path = self._staging_path("url", destination_path) - rewritten = _host_url(url) - local_https = rewritten != url and urlsplit(url).scheme == "https" + rewritten = LocalSandboxProvider.get_external_url(url) + local_https = urlsplit(rewritten).hostname == "host.docker.internal" and urlsplit(rewritten).scheme == "https" try: if local_https: await self.write_host_file(local_ca().bundle_path.read_bytes(), "/etc/agentenv-ca-bundle.pem") @@ -96,4 +94,6 @@ async def persist_ports(self) -> None: async def reconnect(cls, name: str) -> SmolVmSandbox: machine = await asyncio.to_thread(Machine.connect, name, ConnectOptions(target="local", wait_for_ports=False)) data = await asyncio.to_thread(machine.read_file, _PORTS_FILE) - return cls(machine, {int(port): int(host) for port, host in json.loads(data).items()}) + sandbox = cls(machine, {int(port): int(host) for port, host in json.loads(data).items()}) + await sandbox.detect_platform() + return sandbox diff --git a/src/agent_env/store/object_store/local/tls.py b/src/agent_env/store/object_store/local/tls.py index 016159d5..d3ae6d90 100644 --- a/src/agent_env/store/object_store/local/tls.py +++ b/src/agent_env/store/object_store/local/tls.py @@ -32,7 +32,7 @@ from agent_env.store.local_state import ensure_state_dir # What the CA may vouch for: the names containers reach the host by, and loopback and private addresses. -LOCAL_NAMES = ("localhost", "host.docker.internal", "host.smolvm.internal") +LOCAL_NAMES = ("localhost", "host.docker.internal") LOCAL_NETWORKS = tuple( ipaddress.ip_network(n) for n in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7") diff --git a/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py b/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py index dc436b7a..e49cdc1d 100644 --- a/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py +++ b/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py @@ -111,6 +111,7 @@ async def _tenki() -> Sandbox: return await provider.create_vm(exposed_ports=[], setup_for_gateway=False) async def _smol_vm() -> Sandbox: machine = MagicMock(id="agentenv-test") + machine.exec.return_value = SimpleNamespace(exit_code=0, stdout="x86_64\n", stderr="") with ( patch.object(smol_module, "Machine", SimpleNamespace(create=lambda config, conn: machine)), patch.object(smol_module, "_host_port", return_value=44112), diff --git a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py index 1c3af69f..ef9ed42d 100644 --- a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py +++ b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py @@ -4,6 +4,7 @@ import asyncio import json +import subprocess import threading from unittest.mock import AsyncMock, MagicMock from types import SimpleNamespace @@ -12,7 +13,7 @@ from agent_env.providers.sandbox_providers.smol_vm import provider as module from agent_env.providers.sandbox_providers.smol_vm import sandbox as sandbox_module -from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox, _host_url +from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox from agent_env.store.image_store.local_registry_image_store import LocalRegistryImageStore from agent_env.store.object_store.local.store import LocalFilesystemObjectStore from agent_env.store.routing import LocalRunImageStore @@ -59,12 +60,17 @@ def endpoint(self, port): def write_file(self, path, data): created.append((path, data)) + def exec(self, command): + assert command == ["uname", "-m"] + return SimpleNamespace(exit_code=0, stdout="aarch64\n", stderr="") + def delete(self): created.append(("deleted", None)) def create(config, conn): assert conn.target == "local" assert config.wait_for_ports is False + assert config.ttl_seconds == 900 assert config.resources.network is True assert [(p.host, p.guest) for p in config.ports] == [(44000, 8080)] return FakeMachine() @@ -76,8 +82,9 @@ async def setup(self, sandbox): monkeypatch.setattr(module, "_host_port", lambda: 44000) monkeypatch.setattr(module.SmolVmSandboxProvider, "_setup_docker", setup) monkeypatch.setattr(module.SmolVmSandboxProvider, "_configure_host_access", setup) - sandbox = await module.SmolVmSandboxProvider().create_vm(exposed_ports=[8080], memory=1024) + sandbox = await module.SmolVmSandboxProvider().create_vm(exposed_ports=[8080], memory=1024, timeout=900) assert sandbox.tunnel_urls[8080] == "http://127.0.0.1:44000" + assert sandbox.platform == "linux/arm64" assert ("/storage/agentenv-ports.json", b'{"8080": 44000}') in created await sandbox.terminate() assert ("deleted", None) in created @@ -115,6 +122,10 @@ async def test_reconnect_uses_explicit_agent_only_readiness(monkeypatch): class FakeMachine: id = "agentenv-test" + def exec(self, command): + assert command == ["uname", "-m"] + return SimpleNamespace(exit_code=0, stdout="aarch64\n", stderr="") + def read_file(self, path): assert path == "/storage/agentenv-ports.json" return json.dumps({"8080": 44000}).encode() @@ -131,12 +142,48 @@ def connect(name, conn): monkeypatch.setattr("agent_env.providers.sandbox_providers.smol_vm.sandbox.Machine", SimpleNamespace(connect=connect)) sandbox = await SmolVmSandbox.reconnect("agentenv-test") assert sandbox.tunnel_urls == {8080: "http://127.0.0.1:44000"} + assert sandbox.platform == "linux/arm64" assert options[0].target == "local" and options[0].wait_for_ports is False def test_local_urls_are_rewritten_for_vm_host(): - assert _host_url("http://127.0.0.1:5000/v2/") == "http://host.smolvm.internal:5000/v2/" - assert _host_url("https://example.org/path") == "https://example.org/path" + assert module.SmolVmSandboxProvider.get_external_url("http://127.0.0.1:5000/v2/") == "http://host.docker.internal:5000/v2/" + assert module.SmolVmSandboxProvider.get_external_url("https://example.org/path") == "https://example.org/path" + + +@pytest.mark.asyncio +async def test_vm_and_containers_resolve_grant_host_through_smol_gateway(): + sandbox = SimpleNamespace( + exec_with_output=AsyncMock(return_value=(0, "100.96.0.1 STREAM host.smolvm.internal\n", "")), + exec_script=AsyncMock(), + ) + + await module.SmolVmSandboxProvider()._configure_host_access(sandbox) + + assert sandbox.extra_hosts == ("host.docker.internal:100.96.0.1",) + script = sandbox.exec_script.await_args.args[0] + assert "host.docker.internal" in script and "100.96.0.1" in script + assert "/etc/hosts" in script + + +@pytest.mark.asyncio +async def test_vm_host_entry_updates_without_dropping_other_guest_names(tmp_path): + hosts = tmp_path / "hosts" + hosts.write_text("127.0.0.1 localhost host.docker.internal\n::1 ip6-localhost\n") + sandbox = SimpleNamespace( + exec_with_output=AsyncMock(return_value=(0, "100.96.0.1 STREAM host.smolvm.internal\n", "")), + exec_script=AsyncMock(), + ) + provider = module.SmolVmSandboxProvider() + for gateway in ("100.96.0.1", "100.96.0.2"): + sandbox.exec_with_output.return_value = (0, f"{gateway} STREAM host.smolvm.internal\n", "") + await provider._configure_host_access(sandbox) + script = sandbox.exec_script.await_args.args[0].replace("/etc/hosts", str(hosts)) + subprocess.run(["bash", "-c", script], check=True) + lines = hosts.read_text().splitlines() + assert "127.0.0.1 localhost" in lines + assert "::1 ip6-localhost" in lines + assert [line for line in lines if "host.docker.internal" in line] == [f"{gateway} host.docker.internal"] @pytest.mark.asyncio @@ -159,7 +206,7 @@ async def test_local_object_download_uses_a_scoped_https_grant(monkeypatch, tmp_ sandbox.write_host_file.assert_awaited_once() command = sandbox.exec_script.await_args.args[0] assert "SSL_CERT_FILE=/etc/agentenv-ca-bundle.pem" in command - assert "https://host.smolvm.internal:1234/v1/grants/token" in command + assert "https://host.docker.internal:1234/v1/grants/token" in command assert "-o /storage/image.tar.gz" in command diff --git a/tst/unit/store/local_grant_server_test.py b/tst/unit/store/local_grant_server_test.py index 7968d95a..8748d390 100644 --- a/tst/unit/store/local_grant_server_test.py +++ b/tst/unit/store/local_grant_server_test.py @@ -2,7 +2,6 @@ grant cases, and the bounds and refusals S3 would apply to the same requests.""" import os -import ssl import subprocess import sys import threading @@ -336,17 +335,6 @@ def test_the_root_defaults_to_the_state_root(): assert LocalFilesystemObjectStore().root == state_root() / "object_store" -def test_default_bridge_listener_also_serves_loopback_on_the_same_port(store, monkeypatch, tmp_path): - # A second loopback IP stands in for the Linux Docker bridge address. - monkeypatch.setattr(server_module, "default_bind_host", lambda: "127.0.0.2") - bridged = LocalFilesystemObjectStore(str(tmp_path / "bridged"), grant_bind_host="127.0.0.2", grant_advertise_host="localhost") - grant = bridged.issue_read_grant(bridged.put("k", b"both")) - with httpx.Client(verify=ssl.create_default_context(cafile=str(local_ca().bundle_path)), trust_env=False) as client: - assert client.get(grant.url).content == b"both" - assert client.get(grant.url.replace("localhost", "127.0.0.2")).content == b"both" - grant_server("127.0.0.2", "localhost").close() - - class TestDefaultBindHost: @pytest.fixture(autouse=True) def _fresh(self): diff --git a/tst/unit/store/local_tls_test.py b/tst/unit/store/local_tls_test.py index 509e551f..402c97e3 100644 --- a/tst/unit/store/local_tls_test.py +++ b/tst/unit/store/local_tls_test.py @@ -38,10 +38,10 @@ def _handshake(server: ssl.SSLContext, *, cafile: Path, hostname: str) -> None: raise AssertionError("the handshake did not finish") -@pytest.mark.parametrize("hostname", ["host.docker.internal", "host.smolvm.internal", "localhost", "127.0.0.1"]) +@pytest.mark.parametrize("hostname", ["host.docker.internal", "localhost", "127.0.0.1"]) def test_a_server_certificate_verifies_for_the_names_it_was_made_for(hostname): ca = local_ca() - context = server_context(ca, ["localhost", "host.docker.internal", "host.smolvm.internal", "127.0.0.1"]) + context = server_context(ca, ["localhost", "host.docker.internal", "127.0.0.1"]) _handshake(context, cafile=ca.cert_path, hostname=hostname) _handshake(context, cafile=ca.bundle_path, hostname=hostname) @@ -132,14 +132,14 @@ def test_an_unreadable_ca_is_replaced(): @pytest.mark.parametrize("host", [ - "localhost", "host.docker.internal", "host.smolvm.internal", "a.localhost", "127.0.0.1", "172.17.0.1", "192.168.5.2", "10.0.0.8", + "localhost", "host.docker.internal", "a.localhost", "127.0.0.1", "172.17.0.1", "192.168.5.2", "10.0.0.8", "::1", "fd00::1", ]) def test_local_hosts_are_accepted(host): check_local_host(host) -@pytest.mark.parametrize("host", ["example.com", "evil-localhost", "docker.internal", "8.8.8.8", "2001:db8::1"]) +@pytest.mark.parametrize("host", ["example.com", "evil-localhost", "docker.internal", "host.smolvm.internal", "8.8.8.8", "2001:db8::1"]) def test_other_hosts_are_refused(host): with pytest.raises(ValueError): check_local_host(host) diff --git a/tst/unit/store/test_object_transfer_grants.py b/tst/unit/store/test_object_transfer_grants.py index 0050addd..0abb9c38 100644 --- a/tst/unit/store/test_object_transfer_grants.py +++ b/tst/unit/store/test_object_transfer_grants.py @@ -45,6 +45,7 @@ def test_local_store_grants_reach_only_sandboxes_on_this_machine(tmp_path) -> No assert store.supports_transfer_grants assert store.grants_reach("local") + assert store.grants_reach("smol_vm") assert not store.grants_reach("modal") assert not store.grants_reach(None) assert not store.grants_reach("no-such-provider") @@ -87,7 +88,7 @@ def test_custom_private_advertised_host_is_not_claimed_reachable_from_smol_vm(tm def test_loopback_grant_url_is_not_claimed_reachable_from_agent_container(tmp_path, host) -> None: store = LocalFilesystemObjectStore(str(tmp_path), grant_advertise_host=host) - assert store.grants_reach("local") + assert not store.grants_reach("local") assert not store.grants_reach("smol_vm") diff --git a/uv.lock b/uv.lock index 7661f7b9..799806ec 100644 --- a/uv.lock +++ b/uv.lock @@ -1,12 +1,12 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11.4" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.13' and sys_platform != 'win32'", ] @@ -88,6 +88,7 @@ dev = [ { name = "pytest-xdist" }, { name = "requests" }, { name = "sail" }, + { name = "smolmachines" }, { name = "tenki", extra = ["async"] }, { name = "tensorlake" }, { name = "uvicorn" }, @@ -106,6 +107,9 @@ gcp = [ sail = [ { name = "sail" }, ] +smol = [ + { name = "smolmachines" }, +] tenki = [ { name = "tenki", extra = ["async"] }, ] @@ -116,7 +120,7 @@ tensorlake = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", specifier = "==0.3.26" }, - { name = "agentenv-framework", extras = ["aws", "explorer", "gcp", "sail", "tensorlake", "tenki"], marker = "extra == 'dev'" }, + { name = "agentenv-framework", extras = ["aws", "explorer", "gcp", "sail", "smol", "tensorlake", "tenki"], marker = "extra == 'dev'" }, { name = "agentenv-framework-protocol", editable = "packages/agentenv-protocol" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.35.0" }, { name = "botocore", marker = "extra == 'aws'", specifier = ">=1.35.0" }, @@ -150,13 +154,14 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "requests", marker = "extra == 'gcp'", specifier = ">=2.31" }, { name = "sail", marker = "extra == 'sail'", specifier = "~=0.12.8" }, + { name = "smolmachines", marker = "extra == 'smol'", specifier = ">=1.27.0" }, { name = "starlette", specifier = ">=0.40" }, { name = "tenki", extras = ["async"], marker = "extra == 'tenki'", specifier = "~=1.4.1" }, { name = "tensorlake", marker = "extra == 'tensorlake'", specifier = "~=0.5.140" }, { name = "uvicorn", specifier = ">=0.47" }, { name = "uvicorn", marker = "extra == 'explorer'", specifier = ">=0.29" }, ] -provides-extras = ["aws", "explorer", "gcp", "sail", "tensorlake", "tenki", "dev"] +provides-extras = ["aws", "explorer", "gcp", "sail", "tensorlake", "tenki", "dev", "smol"] [[package]] name = "agentenv-framework-protocol" @@ -3352,8 +3357,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -3387,6 +3392,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smolmachines" +version = "1.27.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/3f/03155cbfbe98dbe438f16c533de4aafbcb342d835b02b94c7a91ab5f8788/smolmachines-1.27.3-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:9c9942b478c3ef0f7390e0d2af61f3a51f04b2e1e6827e30c30c64d789018756", size = 46019432, upload-time = "2026-10-13T11:23:24.633Z" }, + { url = "https://files.pythonhosted.org/packages/13/7c/22ce3315a292ad5b1bc86c0c36f524984c39441418e18f9453289d9bf31d/smolmachines-1.27.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3722ffc79c85c6b03942187b65a8577d4df405409d4cd96938308915a98e5f", size = 44920156, upload-time = "2026-10-13T11:23:28.392Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/198cd322d52e8343b451ab215f38e01c69ecd02f1bbd14db8f90db4af6c3/smolmachines-1.27.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9b446b811f1c1f479f294e8f7e3e19fbc4774392bc52a5a588a53c1cfec52228", size = 46628051, upload-time = "2026-10-13T11:23:31.929Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" From 43e4ff837101a37f98fc0a9b366254e8fa221796 Mon Sep 17 00:00:00 2001 From: BinBin He Date: Wed, 14 Oct 2026 01:04:48 -0700 Subject: [PATCH 11/11] fix(smol): keep dev portable and reclaim cancelled machines --- pyproject.toml | 2 +- .../sandbox_providers/smol_vm/provider.py | 48 ++++++++++---- tst/unit/plugins_test.py | 5 +- .../network_policy_stamping_test.py | 5 +- .../sandbox_provider_test.py | 2 + .../smol_vm_provider_test.py | 65 +++++++++++++++++++ tst/unit/store/test_object_transfer_grants.py | 6 +- .../test_optional_extras_stay_optional.py | 11 +++- .../test_runtime_dependencies_are_imported.py | 16 ++++- uv.lock | 3 +- 10 files changed, 140 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 516db124..dc87f157 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ tenki = [ "tenki[async]~=1.4.1", # AsyncClient/AsyncSandbox exec, stdin, ports and egress APIs validated against the 1.4 line ] dev = [ - "agentenv-framework[aws,explorer,gcp,sail,smol,tensorlake,tenki]", # exercise optional providers and stores in CI + "agentenv-framework[aws,explorer,gcp,sail,tensorlake,tenki]", # exercise portable optional providers and stores in CI "fastmcp>=3.4,<4", # the standalone-fastmcp mount path of the protocol; <4 keeps mcp 1.x "griffelib==2.3.0", # the plugin API check; exact, since what it reports as a break changes between releases "moto>=5.0.0", diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py index 07606b5e..05f9c8f9 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py @@ -4,6 +4,7 @@ import asyncio import ipaddress +import logging import math import shlex import socket @@ -11,7 +12,12 @@ from urllib.parse import urlsplit from typing import ClassVar -from smol import ConnectOptions, Machine, MachineConfig, PortSpec, ResourceSpec +try: + from smol import ConnectOptions, Machine, MachineConfig, PortSpec, ResourceSpec +except ModuleNotFoundError as exc: + if exc.name != "smol": + raise + raise ImportError("SmolVM requires pip install 'agentenv-framework[smol]'") from exc from agent_env.attribution import Attribution from agent_env.config import get_config @@ -25,6 +31,31 @@ from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox _DUMMY_PORT = 60832 +logger = logging.getLogger(__name__) +_reapers: set[asyncio.Task] = set() + + +async def _delete_machine(machine: Machine) -> None: + try: + await asyncio.to_thread(machine.delete) + except Exception: + logger.exception("Could not delete SmolVM machine %s after failed creation", machine.id) + + +async def _delete_when_created(creation: asyncio.Task) -> None: + try: + machine = await asyncio.shield(creation) + except Exception: # native creation failed and left no machine to delete + return + await _delete_machine(machine) + + +def _start_reaper(coroutine) -> asyncio.Task: + # Retain the task until cleanup ends, even if a caller is cancelled again. + task = asyncio.create_task(coroutine) + _reapers.add(task) + task.add_done_callback(_reapers.discard) + return task def _host_port() -> int: @@ -85,17 +116,12 @@ async def create_vm(self, *, image: str | None = None, boot_mode: str | None = N try: machine = await asyncio.shield(creation) except asyncio.CancelledError: - # The native create call keeps running after its awaiting task is cancelled. - # Wait for it and delete the result so cancellation cannot orphan a VM. - try: - created = await creation - except Exception: - pass - else: - await asyncio.to_thread(created.delete) + # Native creation continues after cancellation. The independent reaper also + # survives a second cancellation while waiting for the machine to appear. + await asyncio.shield(_start_reaper(_delete_when_created(creation))) raise - sandbox = SmolVmSandbox(machine, ports) try: + sandbox = SmolVmSandbox(machine, ports) await self._setup_docker(sandbox) await sandbox.detect_platform() await self._configure_host_access(sandbox) @@ -103,7 +129,7 @@ async def create_vm(self, *, image: str | None = None, boot_mode: str | None = N sandbox.network_policy = self.effective_network_policy(network_policy) return sandbox except BaseException: - await sandbox.terminate() + await asyncio.shield(_start_reaper(_delete_machine(machine))) raise async def _setup_docker(self, sandbox: SmolVmSandbox) -> None: diff --git a/tst/unit/plugins_test.py b/tst/unit/plugins_test.py index 62688b19..710cb20b 100644 --- a/tst/unit/plugins_test.py +++ b/tst/unit/plugins_test.py @@ -11,6 +11,7 @@ import asyncio import importlib +import importlib.util from abc import abstractmethod import logging import sys @@ -527,7 +528,9 @@ def test_every_built_in_type_implements_what_a_plugin_of_its_group_must(monkeypa *((cls, Env, _ENV_MUST_IMPLEMENT) for cls in config.env_registry().values()), *((cls, TaskStep, _STEP_MUST_IMPLEMENT) for cls in config.task_step_registry().values()), *((cls, Artifact, ()) for cls in config.artifact_registry().values()), - *((load_impl(entry["impl"], SandboxProvider), SandboxProvider, ()) for entry in config.sandbox_registry().values()), + *((load_impl(entry["impl"], SandboxProvider), SandboxProvider, ()) + for name, entry in config.sandbox_registry().items() + if name != "smol_vm" or importlib.util.find_spec("smol") is not None), *((load_impl(entry["impl"], EnvStateProvider), EnvStateProvider, ()) for entry in config.state_registry().values()), *((cls, EnvironmentProvider, ()) for cls in config.env_provider_registry().values()), ] diff --git a/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py b/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py index e49cdc1d..4b71ce8a 100644 --- a/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py +++ b/tst/unit/providers/sandbox_providers/network_policy_stamping_test.py @@ -19,8 +19,6 @@ from agent_env.providers.sandbox_providers.modal_vm_sandbox import ModalVmSandboxProvider from agent_env.providers.sandbox_providers.sail_vm import _sdk as sail_sdk from agent_env.providers.sandbox_providers.sail_vm.provider import SailVmSandboxProvider -from agent_env.providers.sandbox_providers.smol_vm import provider as smol_module -from agent_env.providers.sandbox_providers.smol_vm.provider import SmolVmSandboxProvider from agent_env.providers.sandbox_providers.sandbox import Sandbox from agent_env.providers.sandbox_providers.sandbox_provider import _BUILTIN_SANDBOX_PROVIDERS from agent_env.providers.sandbox_providers.tenki.provider import TenkiSandboxProvider @@ -110,6 +108,9 @@ async def _tenki() -> Sandbox: provider = TenkiSandboxProvider(api_key="test-key", client_cls=MagicMock(return_value=client)) return await provider.create_vm(exposed_ports=[], setup_for_gateway=False) async def _smol_vm() -> Sandbox: + pytest.importorskip("smol") + from agent_env.providers.sandbox_providers.smol_vm import provider as smol_module + from agent_env.providers.sandbox_providers.smol_vm.provider import SmolVmSandboxProvider machine = MagicMock(id="agentenv-test") machine.exec.return_value = SimpleNamespace(exit_code=0, stdout="x86_64\n", stderr="") with ( diff --git a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py index f2abe13b..2340f65f 100644 --- a/tst/unit/providers/sandbox_providers/sandbox_provider_test.py +++ b/tst/unit/providers/sandbox_providers/sandbox_provider_test.py @@ -76,6 +76,8 @@ async def get_sandbox(self, sandbox_id: str) -> Sandbox: @pytest.mark.parametrize("name", sorted(_BUILTIN_SANDBOX_PROVIDERS)) def test_each_builtin_impl_string_loads(name): # A stale string only fails when that backend is built: the registry skips it at DEBUG. + if name == "smol_vm": + pytest.importorskip("smol") assert issubclass(load_impl(_BUILTIN_SANDBOX_PROVIDERS[name], SandboxProvider), SandboxProvider) diff --git a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py index ef9ed42d..85c0a4a6 100644 --- a/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py +++ b/tst/unit/providers/sandbox_providers/smol_vm_provider_test.py @@ -11,6 +11,8 @@ import pytest +pytest.importorskip("smol") + from agent_env.providers.sandbox_providers.smol_vm import provider as module from agent_env.providers.sandbox_providers.smol_vm import sandbox as sandbox_module from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox @@ -115,6 +117,69 @@ def create(config, conn): assert deleted == [True] +@pytest.mark.asyncio +async def test_second_cancellation_during_create_still_deletes_machine(monkeypatch): + started, release, deleted = threading.Event(), threading.Event(), threading.Event() + + class FakeMachine: + def delete(self): + deleted.set() + + def create(config, conn): + started.set() + assert release.wait(timeout=10) + return FakeMachine() + + monkeypatch.setattr(module, "Machine", SimpleNamespace(create=create)) + monkeypatch.setattr(module, "_host_port", lambda: 44000) + task = asyncio.create_task(module.SmolVmSandboxProvider().create_vm(memory=1024)) + try: + assert await asyncio.to_thread(started.wait, 5) + task.cancel() + for _ in range(100): + if module._reapers: + break + await asyncio.sleep(0) + assert module._reapers + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + release.set() + assert await asyncio.to_thread(deleted.wait, 5) + + +@pytest.mark.asyncio +async def test_second_cancellation_during_setup_still_deletes_machine(monkeypatch): + started, deleted = asyncio.Event(), threading.Event() + + class FakeMachine: + id = "agentenv-test" + + def delete(self): + deleted.set() + + async def setup(self, sandbox): + started.set() + await asyncio.Future() + + monkeypatch.setattr(module, "Machine", SimpleNamespace(create=lambda config, conn: FakeMachine())) + monkeypatch.setattr(module, "_host_port", lambda: 44000) + monkeypatch.setattr(module.SmolVmSandboxProvider, "_setup_docker", setup) + task = asyncio.create_task(module.SmolVmSandboxProvider().create_vm(memory=1024)) + await asyncio.wait_for(started.wait(), 5) + task.cancel() + for _ in range(100): + if module._reapers: + break + await asyncio.sleep(0) + assert module._reapers + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert await asyncio.to_thread(deleted.wait, 5) + + @pytest.mark.asyncio async def test_reconnect_uses_explicit_agent_only_readiness(monkeypatch): options = [] diff --git a/tst/unit/store/test_object_transfer_grants.py b/tst/unit/store/test_object_transfer_grants.py index 0abb9c38..38baa5c8 100644 --- a/tst/unit/store/test_object_transfer_grants.py +++ b/tst/unit/store/test_object_transfer_grants.py @@ -45,12 +45,16 @@ def test_local_store_grants_reach_only_sandboxes_on_this_machine(tmp_path) -> No assert store.supports_transfer_grants assert store.grants_reach("local") - assert store.grants_reach("smol_vm") assert not store.grants_reach("modal") assert not store.grants_reach(None) assert not store.grants_reach("no-such-provider") +def test_smol_vm_reaches_local_store_when_its_sdk_is_installed(tmp_path) -> None: + pytest.importorskip("smol") + assert LocalFilesystemObjectStore(str(tmp_path)).grants_reach("smol_vm") + + @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") diff --git a/tst/unit/test_optional_extras_stay_optional.py b/tst/unit/test_optional_extras_stay_optional.py index 0994e983..5239f681 100644 --- a/tst/unit/test_optional_extras_stay_optional.py +++ b/tst/unit/test_optional_extras_stay_optional.py @@ -26,10 +26,11 @@ def find_spec(self, fullname, path=None, target=None): sys.meta_path.insert(0, Refuse()) """ -# Not core: the explorer and the sail_vm provider are their extras' own packages, the gateway runs +# Not core: the explorer, Sail and Smol providers need their optional extras, and the gateway runs # only in its container image, and the code runner is a script that reads its arguments on import. _NOT_CORE = ( - "agent_env.explorer", "agent_env.providers.sandbox_providers.sail_vm", "agent_env.env.gateway", + "agent_env.explorer", "agent_env.providers.sandbox_providers.sail_vm", + "agent_env.providers.sandbox_providers.smol_vm", "agent_env.env.gateway", "agent_env.task_step.task_steps.run_code_runner", ) @@ -190,9 +191,13 @@ def test_core_and_every_store_module_import_without_any_extra(): "agent_env.providers.sandbox_providers.tenki.provider:TenkiSandboxProvider", "agent_env.providers.sandbox_providers.sandbox_provider:SandboxProvider", ), + ( + "smol", "agent_env.providers.sandbox_providers.smol_vm.provider:SmolVmSandboxProvider", + "agent_env.providers.sandbox_providers.sandbox_provider:SandboxProvider", + ), ], ids=["gcs", "secret-manager", "s3", "s3-reexport", "secrets-manager-reexport", "dynamodb", "dynamodb-reexport", "tensorlake", - "tenki"], + "tenki", "smol"], ) def test_an_impl_without_its_extra_names_the_extra_to_install(extra, impl, base): message = _run(_LOAD_IMPL, _only_in(extra), impl, base) diff --git a/tst/unit/test_runtime_dependencies_are_imported.py b/tst/unit/test_runtime_dependencies_are_imported.py index 6dd17afb..74034daa 100644 --- a/tst/unit/test_runtime_dependencies_are_imported.py +++ b/tst/unit/test_runtime_dependencies_are_imported.py @@ -20,6 +20,8 @@ PYPROJECT = SRC.parents[1] / "pyproject.toml" CONTAINER_ONLY = (SRC / "env" / "gateway",) SELF = "agentenv-framework" +# The Smol SDK has no wheel for some development hosts and is deliberately absent from [dev]. +_UNINSTALLED_OPTIONAL_MODULES = {"smolmachines": frozenset({"smol"})} def _normalize(name: str) -> str: @@ -53,7 +55,13 @@ def _import_names(distribution: str) -> frozenset[str]: } # Editable installs (the in-repo protocol package) publish no top-level map, and that distribution's name # differs from its import package's, so read the packages under the directory its .pth file adds. - return frozenset(names or _editable_import_names(distribution) or {distribution.replace("-", "_")}) + if names: + return frozenset(names) + try: + editable = _editable_import_names(distribution) + except importlib.metadata.PackageNotFoundError: + return _UNINSTALLED_OPTIONAL_MODULES[distribution] + return frozenset(editable or {distribution.replace("-", "_")}) def _editable_import_names(distribution: str) -> set[str]: @@ -73,7 +81,11 @@ def _editable_import_names(distribution: str) -> set[str]: def _module_paths(distribution: str) -> frozenset[str]: """Every dotted module path the installed distribution's files provide, namespaces included.""" paths: set[str] = set() - for file in importlib.metadata.distribution(distribution).files or []: + try: + files = importlib.metadata.distribution(distribution).files or [] + except importlib.metadata.PackageNotFoundError: + return _UNINSTALLED_OPTIONAL_MODULES[distribution] + for file in files: if file.suffix != ".py" or ".." in file.parts: continue parts = file.with_suffix("").parts diff --git a/uv.lock b/uv.lock index 799806ec..21faefeb 100644 --- a/uv.lock +++ b/uv.lock @@ -88,7 +88,6 @@ dev = [ { name = "pytest-xdist" }, { name = "requests" }, { name = "sail" }, - { name = "smolmachines" }, { name = "tenki", extra = ["async"] }, { name = "tensorlake" }, { name = "uvicorn" }, @@ -120,7 +119,7 @@ tensorlake = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", specifier = "==0.3.26" }, - { name = "agentenv-framework", extras = ["aws", "explorer", "gcp", "sail", "smol", "tensorlake", "tenki"], marker = "extra == 'dev'" }, + { name = "agentenv-framework", extras = ["aws", "explorer", "gcp", "sail", "tensorlake", "tenki"], marker = "extra == 'dev'" }, { name = "agentenv-framework-protocol", editable = "packages/agentenv-protocol" }, { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.35.0" }, { name = "botocore", marker = "extra == 'aws'", specifier = ">=1.35.0" },