Skip to content

feat(sandbox): add SmolVM local sandbox provider - #157

Open
BinSquare wants to merge 9 commits into
scaleapi:mainfrom
BinSquare:feat/smol-sandbox-provider
Open

BinSquare wants to merge 9 commits into
scaleapi:mainfrom
BinSquare:feat/smol-sandbox-provider

Conversation

@BinSquare

@BinSquare BinSquare commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Adds a local SmolVM sandbox provider for isolated AgentEnv agents and environments, including published ports, persistent Docker storage, local registry access, and trusted object transfers; this is the local phase of #156.

Validation: 6,817 AgentEnv unit tests passed locally and the plugin API check passed; live Linux KVM checks covered container networking, published HTTP, HTTPS grants, registry pulls, reconnect, and VM stop/start. On live Linux KVM, the explicitly run checks deployed an A2A agent in SmolVM (1 passed in 26 seconds) and read environment data from its container through a gateway in a second SmolVM machine after restoring the environment record (1 passed in 89 seconds); reproduce with Docker and the SDK reconnect fix using python -m pytest tst/manual/smol_vm_agent_check.py tst/manual/smol_vm_gateway_check.py -q --timeout=1200. A 64 MiB local object transferred into a live VM through a scoped HTTPS grant in 0.26 seconds with matching SHA-256.

Cloud follow-up: cloud VM endpoints require an Authorization header, so cloud gateway access needs a scoped private credential or authenticated proxy before AgentEnv can safely persist and return its gateway URL.

Dependency: The opt-in reconnect behavior from smol-machines/smol#277 is published in smolmachines 1.27.0, the optional minimum and locked version for this PR.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; the custom-provider compatibility issue is fixed.

Summary

Adds an optional local SmolVM provider for agents and environments.

  • Agents and environments can run in their own local SmolVM machines.
  • SmolVM guests can reach local services and object stores on the host.
  • Container deployments use a separate provider call and keep fallback behavior.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Container deployment] --> B[create_container]
  B --> C{Provider implements create_vm?}
  C -->|No| D[Delegate to custom create_sandbox]
  C -->|Yes| E[Create VM]
  E --> F[Pull image and start container]
  G[SmolVM agent deployment] --> H[Create bare VM]
  H --> I[Load image and start agent]
Loading

Reviews (4) · Last reviewed commit: "fix(sandbox): preserve custom container ..." · Reviewed by Greptile

@socket-security

socket-security Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpypi/​smolmachines@​1.27.098100100100100

View full report

@earakely-scale

Copy link
Copy Markdown
Collaborator

Hi @BinSquare , appreciate you working on integrating Smol Machines to AgentEnv. Would love to collab on co-marketing for once this is merged. Feel free to reach out to me over X https://x.com/earakely to discuss if you're interested, we can setup a shared slack channel.

@BinSquare

Copy link
Copy Markdown
Contributor Author

Hi @BinSquare , appreciate you working on integrating Smol Machines to AgentEnv. Would love to collab on co-marketing for once this is merged. Feel free to reach out to me over X https://x.com/earakely to discuss if you're interested, we can setup a shared slack channel.

That sounds great, would love to.

I'll msg you as I finish up testing

@BinSquare
BinSquare force-pushed the feat/smol-sandbox-provider branch from 2bb090d to abea4cd Compare October 12, 2026 02:06
@BinSquare
BinSquare marked this pull request as ready for review October 12, 2026 03:32
@BinSquare
BinSquare requested a review from a team as a code owner October 12, 2026 03:32
Comment thread src/agent_env/providers/sandbox_providers/smol_vm/provider.py Outdated
Comment thread src/agent_env/providers/sandbox_providers/smol_vm/provider.py Outdated
Comment thread src/agent_env/store/object_store/local/store.py Outdated
Comment thread src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py Outdated
Comment thread src/agent_env/providers/sandbox_providers/smol_vm/provider.py
Comment thread src/agent_env/task_step/task_steps/deploy_sandbox.py
@earakely-scale

Copy link
Copy Markdown
Collaborator

Thanks for the provider, and for working through the Greptile rounds. I ran this branch merged onto current main (047f835): the merge is clean, uv lock --check is clean, the plugin-API check reports no break, and the unit suite is 7546 passed with one failure, covered in 3 below. I also booted a SmolVM on an Apple Silicon Mac with smolmachines 1.27.0 and ran your _setup_docker steps by hand, which is where 1 comes from. Three things before this can merge:

1. The sandbox declares the wrong platform on Apple Silicon. Since #159 every sandbox carries the platform of the images it runs, defaulting to linux/amd64, and VmSandbox.load_docker_images refuses an image built for another platform before loading anything. SmolVmSandbox keeps that default, but the guest matches the host: on the Mac the probe reported uname -m = aarch64 (Alpine 3.19, kernel 6.18.55), and

$ docker run --rm --platform linux/amd64 alpine:3.20 uname -m
exec /bin/uname: exec format error

There are no binfmt entries, and Alpine 3.19 has no qemu-x86_64 package. So on the Macs the README names, the platform check waves through agent-env's default amd64 builds, which then die at run time, and would refuse an arm64 image with "the sandbox runs linux/amd64". Read the guest architecture at create and at reconnect (uname -m, through agent_env.utils.docker_build.normalize_platform) and set platform on the instance, then pick one of:

  • declare it as is, so amd64 images are refused up front with the rebuild message, and say in the README that Apple Silicon needs images built for linux/arm64; or
  • install emulation in arm64 guests in _setup_docker (the kernel has binfmt_misc; docker run --privileged --rm tonistiigi/binfmt --install amd64 is the usual way) and set platform = None, "any platform, unchecked", as LocalSandbox does.

Either way the README's Apple Silicon sentence should describe what was actually run; the validation in the description is Linux KVM only.

2. The TTL is dropped. create_vm takes timeout and never uses it, and the machine is persistent=True, so a run that crashes or is killed leaves VMs behind with nothing to reap them. MachineConfig in 1.27.0 has ttl_seconds (and auto_stop_seconds); pass ttl_seconds=timeout so the guest goes away on its own like every other provider's sandbox does.

3. One new unit test is Linux-only. test_default_bridge_listener_also_serves_loopback_on_the_same_port binds 127.0.0.2, which Linux has on lo but macOS does not:

GrantUnavailableError: the local grant server cannot listen on 127.0.0.2: [Errno 49] Can't assign requested address

The unit tier runs on developers' Macs, so mark it pytest.mark.skipif(platform.system() != "Linux", ...), or alias the address first.

Once you push, the fork CI needs a maintainer to approve the run; ping here and we'll do it.

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.

@earakely-scale

Copy link
Copy Markdown
Collaborator

#178 is merged (fe6c137), so core now reads what a provider declares and this PR can lose its edits outside smol_vm/. After rebasing onto main:

Drop, because core does it now

Add, all inside smol_vm/

  • REACHES_HOST_AT: ClassVar[str | None] = "127.0.0.1": the address this machine receives the guest's traffic at, which is what grants_reach checks the server listens on;
  • one line in _configure_host_access, where you already resolve the gateway, adding <gateway> host.docker.internal to the guest's /etc/hosts, so the VM resolves this machine by the same name its containers already do through extra_hosts;
  • _host_url, get_external_url and url_from_sandbox become LocalSandboxProvider.get_external_url, since the alias is now the same one.

host.smolvm.internal then appears only where you resolve the gateway and as the registry proxy's target. I ran exactly this on top of main against a live SmolVM here: VM-level grant download, agent container fetching a grant as issued with the CA installed through the core hook, host_url_for rewriting to a URL both the VM and its containers reach, clean teardown. The diff of the two files is below; the earlier comment's three items (platform on Apple Silicon, ttl_seconds, and the Linux-only test, which you can now drop along with the grant-server change) still stand.

smol_vm on main after #178
--- 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,9 @@
 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
+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 SandboxProvider, refuse_unenforceable_policy
+from agent_env.providers.sandbox_providers.smol_vm.sandbox import SmolVmSandbox
 
 _DUMMY_PORT = 60832
 
@@ -37,9 +35,7 @@
     _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
+    REACHES_HOST_AT: ClassVar[str | None] = "127.0.0.1"  # the guest's gateway forwards this machine's names to its loopback
 
     @classmethod
     def shares_network_with(cls, sandbox_type: str | None) -> bool:
@@ -47,10 +43,10 @@
 
     @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,
@@ -161,6 +157,10 @@
             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}",)
+        # The VM itself resolves this machine by the name its containers (extra_hosts) and the framework use.
+        await sandbox.exec_script(
+            f"grep -q ' host.docker.internal$' /etc/hosts || echo '{gateway} host.docker.internal' >> /etc/hosts"
+        )
 
     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)
--- a/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py
+++ b/src/agent_env/providers/sandbox_providers/smol_vm/sandbox.py
@@ -11,22 +11,17 @@
 from smol import ConnectOptions, Machine
 
 from agent_env.config import get_config
+from agent_env.providers.sandbox_providers.local_sandbox import LocalSandboxProvider
 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
 
 _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}"))
+    return LocalSandboxProvider.get_external_url(url)
 
 
 class SmolVmSandbox(VmSandbox):

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants