Repository navigation
feat(sandbox): add SmolVM local sandbox provider #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
BinSquare
wants to merge
9
commits into
scaleapi:main
Choose a base branch
from
BinSquare:feat/smol-sandbox-provider
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+882
−35
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
91c6058
feat(sandbox): add SmolVM local sandbox provider
BinSquare bc6a32d
fix(sandbox): preserve lazy imports and clean up cancelled VMs
BinSquare 320948a
perf(sandbox): stream local image artifacts over scoped grants
BinSquare 85ea0d0
test(sandbox): preserve live SmolVM gateway validation
BinSquare abea4cd
test(sandbox): validate agent workloads across SmolVM machines
BinSquare 2c7e5db
chore(smol): require published smolmachines 1.27.0
BinSquare c5965b6
fix(sandbox): address SmolVM review findings
BinSquare a463015
fix(sandbox): start container-mode workloads on VM providers
BinSquare b67f728
fix(sandbox): preserve custom container provider deployments
BinSquare File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
176
src/agent_env/providers/sandbox_providers/smol_vm/provider.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
|
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) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 andLOCAL_NAMES. The rule in this repo (AGENTS.md;provider_capabilities_test.pyenforces 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_containerpasses--add-hostfor every entry in the sandbox'sextra_hosts, and the gateway compose takessandbox.extra_hoststoo, so with yourextra_hosts = ("host.docker.internal:<gateway>",)the agent container and the gateway's services already resolvehost.docker.internalto the right place. Confirmed live on a SmolVM: an agent-like container started with that--add-hostfetched a grant URL as issued (https://host.docker.internal:…) with the local CA. The only thing in a SmolVM that cannot resolvehost.docker.internalis the VM itself, which is why your VM-level downloads rewrite tohost.smolvm.internal.So the simplest fix is to give the VM the alias everything else already uses:
_configure_host_access, where you already resolve the gateway, add<gateway> host.docker.internalto the guest's/etc/hosts. Then_download_object_to_vmandwrite_file_from_urlcan curl grant URLs as issued: no rewrite, no new certificate name._host_urlbecomesLocalSandboxProvider.get_external_url(loopback →host.docker.internal), which already works in your containers viaextra_hostsand, after the line above, in the VM.LOCAL_NAMES, the grant server's SAN list and the alias list ingrants_reachrevert;host.smolvm.internalis then used only insidesmol_vm/, to find the gateway and as the registry proxy's target. That also avoids a side effect of the current change:_loadre-mints the CA whenever its stored name constraints differ from the code's, so touchingLOCAL_NAMESrotates every developer's local CA on upgrade.host.docker.internal", and that is exactlyON_THIS_MACHINE. Here,host_url_forapplies 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), andLocalFilesystemObjectStore.grants_reachreads the same flag plus your check that the advertised host is not a loopback literal. Both type-string cases go away, and one sentence onON_THIS_MACHINErecords 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_reachgeneralisation in this PR, say so and we'll land it first and you rebase onto it.