Skip to content
Open
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ uv sync --extra dev
source .venv/bin/activate
```

## 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.
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ sail = [
"sail~=0.12.8", # Sailbox exec/fs/listener/egress APIs validated against the 0.12 line
]
dev = [
"agentenv-framework[aws,explorer,gcp,sail]", # so the AWS, explorer, GCP backend and Sail provider tests run in CI
"agentenv-framework[aws,explorer,gcp,sail,smol]", # exercise the 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",
Expand All @@ -85,6 +85,9 @@ dev = [
"pytest-timeout>=2.3.0",
"pytest-xdist>=3.0.0",
]
smol = [
"smolmachines>=1.27.0",
]

[project.scripts]
agent-env = "agent_env.cli:cli"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
Accepts,
SandboxProvider,
refuse_unenforceable_policy,
_sandbox_provider_class,
)
from agent_env.store.object_store.local.tls import local_ca
from agent_env.store.object_store.local.store import LocalFilesystemObjectStore
Expand Down Expand Up @@ -259,6 +260,9 @@ def _rewrite_app_arg(self, arg: str) -> str:
def _rewrite_app_script(self, script: str) -> str:
return _APP_PATH_PATTERN.sub(lambda _: str(self._work_dir), script)

async def copy_trust_dir_into_container(self, trust_dir: Path, container: str, destination: str) -> None:
await asyncio.to_thread(_copy_into_container, trust_dir, container, destination)

async def terminate(self) -> None:
"""Tear down whatever this sandbox is running.

Expand Down Expand Up @@ -507,7 +511,7 @@ async def start_trusting(sandbox: VmSandbox, container: str, trust_dir: Path) ->
"""Copy ``trust_dir`` into the created ``container`` where ``LOCAL_TRUST_ENV`` points, then start it. A container
that cannot be given the files or started is removed, so its name is free for the next attempt."""
try:
await asyncio.to_thread(_copy_into_container, trust_dir, container, _TRUST_DIR)
await sandbox.copy_trust_dir_into_container(trust_dir, container, _TRUST_DIR)
await sandbox.exec_script(f"docker start {shlex.quote(container)} > /dev/null")
except Exception:
await sandbox.exec_script(f"docker rm -f {shlex.quote(container)} >/dev/null 2>&1 || true")
Expand Down Expand Up @@ -684,6 +688,8 @@ def get_external_url(cls, url: str) -> str:
def host_url_for(url: str, sandbox_type: Optional[str]) -> str:
"""``url``, which this machine reaches, as a container on ``sandbox_type`` reaches it: only a local
sandbox's containers run on this machine, and they reach its loopback by another name."""
if sandbox_type == "smol_vm":

@earakely-scale earakely-scale Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is the one place the PR teaches core about a particular provider by name, and the same pattern repeats in grants_reach("smol_vm"), the grant server's SAN list and LOCAL_NAMES. The rule in this repo (AGENTS.md; provider_capabilities_test.py enforces part of it) is that code outside a provider reads what the provider declares, never which provider it has. There is a route to the same behaviour with none of those four edits, and it is simpler than what I first wrote here.

Correction to the earlier version of this comment. I claimed the agent container could only resolve a name SmolVM's DNS serves. That was wrong: A2AAgent._run_container passes --add-host for every entry in the sandbox's extra_hosts, and the gateway compose takes sandbox.extra_hosts too, so with your extra_hosts = ("host.docker.internal:<gateway>",) the agent container and the gateway's services already resolve host.docker.internal to the right place. Confirmed live on a SmolVM: an agent-like container started with that --add-host fetched a grant URL as issued (https://host.docker.internal:…) with the local CA. The only thing in a SmolVM that cannot resolve host.docker.internal is the VM itself, which is why your VM-level downloads rewrite to host.smolvm.internal.

So the simplest fix is to give the VM the alias everything else already uses:

  • In _configure_host_access, where you already resolve the gateway, add <gateway> host.docker.internal to the guest's /etc/hosts. Then _download_object_to_vm and write_file_from_url can curl grant URLs as issued: no rewrite, no new certificate name.
  • _host_url becomes LocalSandboxProvider.get_external_url (loopback → host.docker.internal), which already works in your containers via extra_hosts and, after the line above, in the VM.
  • LOCAL_NAMES, the grant server's SAN list and the alias list in grants_reach revert; host.smolvm.internal is then used only inside smol_vm/, to find the gateway and as the registry proxy's target. That also avoids a side effect of the current change: _load re-mints the CA whenever its stored name constraints differ from the code's, so touching LOCAL_NAMES rotates every developer's local CA on upgrade.
  • What remains is "which providers' containers reach this machine as host.docker.internal", and that is exactly ON_THIS_MACHINE. Here, host_url_for applies the loopback rewrite when _sandbox_provider_class(sandbox_type).ON_THIS_MACHINE (returning the URL unchanged when the class can't be loaded, which is today's behaviour), and LocalFilesystemObjectStore.grants_reach reads the same flag plus your check that the advertised host is not a loopback literal. Both type-string cases go away, and one sentence on ON_THIS_MACHINE records the convention.

The dual bind in the grant server stays as it is; only its comment names SmolVM. If you'd rather not carry the host_url_for / grants_reach generalisation in this PR, say so and we'll land it first and you rebase onto it.

return _sandbox_provider_class(sandbox_type).get_external_url(url)
return LocalSandboxProvider.get_external_url(url) if sandbox_type == LocalSandbox.type else url


Expand Down
12 changes: 12 additions & 0 deletions src/agent_env/providers/sandbox_providers/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, replace
from enum import Enum
from pathlib import Path
from typing import IO, TYPE_CHECKING, Any, AsyncIterator, Callable, ClassVar, Iterable, Optional

from agent_env.config import get_config
Expand Down Expand Up @@ -473,6 +474,17 @@ async def docker_cp(self, source: str, destination: str, *, remove_source: bool
f"docker cp {source} {destination} failed (exit {exit_code}):\nstdout: {stdout[-1500:]}\nstderr: {stderr[-1500:]}"
)

async def copy_trust_dir_into_container(self, trust_dir: Path, container: str, destination: str) -> None:
"""Install the local transfer CA in a container on this VM's Docker host."""
vm_dir = self._staging_path("trust", container)
await self.exec_script(f"mkdir -p {shlex.quote(vm_dir)}")
try:
for path in trust_dir.iterdir():
await self.write_host_file(await asyncio.to_thread(path.read_bytes), f"{vm_dir}/{path.name}")
await self.docker_cp(f"{vm_dir}/.", f"{container}:{destination}")
finally:
await self.exec_script(f"rm -rf {shlex.quote(vm_dir)}")

async def _copy_into_container(self, vm_path: str, destination_path: str) -> None:
parent = os.path.dirname(destination_path)
if parent:
Expand Down
12 changes: 10 additions & 2 deletions src/agent_env/providers/sandbox_providers/sandbox_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -287,6 +294,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",
"local": "agent_env.providers.sandbox_providers.local_sandbox:LocalSandboxProvider",
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Local SmolVM sandbox provider (imported only when the optional extra is selected)."""
176 changes: 176 additions & 0 deletions src/agent_env/providers/sandbox_providers/smol_vm/provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""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.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 (
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):
_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
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_vm(
exposed_ports=[port], cpu=cpu, memory=memory, disk_size_gb=disk_size_gb,
timeout=timeout, attribution=attribution, network_policy=network_policy,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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 [])}
# 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()],
)
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)
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(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
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 {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
""")
await self._ensure_local_registry_proxy(sandbox)

async def _ensure_local_registry_proxy(self, sandbox: SmolVmSandbox) -> None:
store = get_config().get_image_store()
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 &
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)
Loading
Loading