Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e28d05f
feat(computer): Windows disks and their bases live in the image store…
earakely-scale Nov 7, 2026
40b65ff
feat(computer): boot Windows disks on modal_vm
earakely-scale Nov 7, 2026
d5919b5
test(computer): the thin export's fake Docker removes its container too
earakely-scale Nov 7, 2026
b9a7d4d
refactor(computer): name the disk image's platform on modal_vm; no fa…
earakely-scale Nov 7, 2026
1373672
fix(computer): pull a stored Windows base under its lock, and push a …
earakely-scale Nov 7, 2026
da7639d
Merge the image-store branch's fixes
earakely-scale Nov 7, 2026
8651111
fix(computer): an export fails on a guest file docker can't copy, rat…
earakely-scale Nov 7, 2026
36e6bac
fix(computer): an export killed outright leaves no copy of a base behind
earakely-scale Nov 7, 2026
56a97d0
fix(computer): name an export's container by its work folder's whole …
earakely-scale Nov 7, 2026
fa5aefb
refactor(computer): one store lookup for Linux and Windows disks, Win…
earakely-scale Nov 7, 2026
f198fd3
Merge the image-store branch's refactor
earakely-scale Nov 8, 2026
d506dff
Merge main, which holds the image-store branch squashed
earakely-scale Nov 8, 2026
8a46005
docs(computer): Windows computers boot on modal_vm, their base carrie…
earakely-scale Nov 8, 2026
55e2ac6
refactor(computer): one thin-disk rule, the export's cache named in o…
earakely-scale Nov 8, 2026
eb372a6
revert(computer): Windows keeps the default ready timeout, which ever…
earakely-scale Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions src/agent_env/computers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ before reset in place started is refused a reset, to be redeployed. It boots on
Docker can pass it `/dev/kvm`, and on `modal_vm`, amd64 only, whose sandbox runs the launcher as its image under nested
virtualization: the provider exports the disk from the image store with this machine's Docker, compressed, once for each
composed disk, into the per-user cache, and mounts it on the sandbox at create, which Modal uploads once for each
content and the launcher expands onto the sandbox's disk. Such a sandbox is ended once it has been idle, with no
connection open, for the provider's `idle_timeout`, 30 minutes unless its config sets another, as a running QEMU isn't
activity. A gate 3 probe that ends with the connection to the driver dropped, refused or timed out runs again on a fresh
boot, up to three in all, and the record keeps each attempt; one with a failed check is recorded as it is.
content and the launcher expands onto the sandbox's disk; a thin disk, a Windows one, carries its base too, exported
once for each base and expanded beside it there, so another computer on that base adds its layer alone. Such a sandbox
is ended once it has been idle, with no connection open, for the provider's `idle_timeout`, 30 minutes unless its config
sets another, as a running QEMU isn't activity. A gate 3 probe that ends with the connection to the driver dropped,
refused or timed out runs again on a fresh boot, up to three in all, and the record keeps each attempt; one with a
failed check is recorded as it is.

A put, `env build` and the release compose every form; a run composes only the forms its sandbox's deploys boot: on each
link of a chain, the first form it boots natively, as a deploy picks, so local Docker composes containers, and a sandbox
Expand Down Expand Up @@ -170,10 +172,10 @@ pulls it. Gate 3 probes it at full. At the session user's sign-in,
`agentenv-session`, run windowless by `pythonw`, reads the deploy's environment from the NoCloud seed, which the
launcher attaches as a removable USB drive, since Windows mounts a FAT filesystem with no partition table on removable
media alone, and starts each layer's services in that session, again whenever one exits. A Windows computer boots
with 4 vCPUs and 8 GB, on local Docker with KVM; it has computer-exec and data/add, through the protocol's Windows
defaults, so the probe runs its fixture there as on Linux, with Python one-liners for the commands it would give a
shell. A Windows computer composes its driver alone for now, no app, and boots on no Modal VM sandbox yet, whose export
carries a disk's top layer alone.
with 4 vCPUs and 8 GB, on local Docker with KVM and on `modal_vm`; it has computer-exec and data/add, through the
protocol's Windows defaults, so the probe runs its fixture there as on Linux, with Python one-liners for the commands it
would give a shell. On `modal_vm` it runs in your own Modal workspace, under the evaluation's terms or a licence of your
own; whether they allow it there is yours to check. A Windows computer composes its driver alone for now, no app.

This bundle has no tasks of its own: other bundles pull its computers by alias, and `agent-env run computers` has
nothing to run.
5 changes: 5 additions & 0 deletions src/agent_env/computers/disk/linux/launcher/launch
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
# and, under KVM, Hyper-V enlightenments.
# VM_DISK the disk, /disk/disk.img by default, which may be backed by another beside it; one ending .zst is
# expanded onto this machine's disk first
# VM_BASE the base of a thin VM_DISK ending .zst, compressed too, which a provider carries beside it: expanded
# beside the expanded disk, as the base.img it's backed by
# VM_ENV_<NAME> the computer's variable NAME, its value base64'd, for its NoCloud seed (CIDATA; see seed)
# VM_PORTS guest TCP ports, each served on the same port here by socat, whose listen backlog takes a burst of
# connections, in front of QEMU's forward on 127.0.0.1 at the port plus 10000, whose backlog is 1;
Expand Down Expand Up @@ -111,6 +113,9 @@ trap 'kill "$(cat "$control/qemu.pid" 2>/dev/null)" 2>/dev/null; exit 143' TERM
if [[ "$disk" == *.zst ]]; then
zstd -q -d -f "$disk" -o "$state/disk.img"
disk=$state/disk.img
if [ -n "${VM_BASE:-}" ]; then
zstd -q -d -f "$VM_BASE" -o "$state/base.img"
fi
fi

boot() {
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/providers/sandbox_providers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ with `exposed_ports=[port]` and returns the VM, and the deploy then loads and st
| `create_container(...)` | inherited | containers that run an env or agent image. Override only when the platform boots images natively |
| `reset_container(sandbox, *, image_name, port, env)` | inherited | a computer env's reset, which replaces the container `create_container` ran with a fresh one on the same sandbox and port. The default works on a VM backend; one whose sandbox is the container itself raises `NotImplementedError` |
| `native_platforms()` | no | a computer env's deploy and gate 3, which boot a build only on a provider that runs its arch without emulation. The default is `linux/amd64`; local Docker returns this machine's |
| `disk_image_platforms()` | no | the same for a computer's disk form: the platforms whose disks `create_vm` boots without emulation. The default is none; local Docker returns this machine's where it can pass `/dev/kvm` to a container, and `modal_vm` `linux/amd64`, booting the launcher as its sandbox's image under nested virtualization |
| `disk_image_platforms()` | no | the same for a computer's disk form: the platforms whose disks `create_vm` boots without emulation. The default is none; local Docker returns this machine's where it can pass `/dev/kvm` to a container, and `modal_vm` `linux/amd64` and `windows/amd64`, booting the launcher as its sandbox's image under nested virtualization, a Windows disk's base carried beside it |
| `vm_bundle_platforms()` | no | the same for a computer's vm_bundle form, a whole saved VM (`format = "lume"`): the platforms, as `<os>/<arch>`, whose VM bundles `create_vm` boots. The default is none; `lume` returns `macos/arm64` on an Apple Silicon Mac. A provider that names one implements the VM-bundle methods below too |
| `reset_vm(sandbox, *, image, env)` | no | a computer env's reset of a disk `create_vm` booted: boot it afresh on the same sandbox and ports. The default raises `NotImplementedError`; local Docker and `modal_vm` run the launcher's `launch reset` in the sandbox hosting it, with `env` on its stdin |
| `has_vm_bundle(image)`, `delete_vm_bundle(image)`, `open_builder(image, name, build)`, `keep_builder(builder)`, `ssh_password` | when `vm_bundle_platforms()` names a platform | a macOS computer's compose, and a deploy on a Mac that doesn't hold the env's VM bundle: `open_builder` boots a clone of the base `image`, named `name`, with the folder `build` (named `agentenv-build`) shared read-write, and its sandbox's `exec_with_output` runs over ssh as the session user, whose sudo reads `ssh_password` from that folder; `keep_builder` shuts it down from inside and keeps it, returning `local:<name>`; `has_vm_bundle` says whether this machine holds a `local:` VM bundle, and `delete_vm_bundle` deletes one, as a compose does one that failed its probe. Here `create_vm` boots `image`, `local:<name>` or a registry reference pinned by digest, with `env` as the protocol's seed in a folder the VM shares, and `reset_vm` clones it afresh. See `lume/provider.py` |
Expand Down
163 changes: 129 additions & 34 deletions src/agent_env/providers/sandbox_providers/local_docker/disk.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import asyncio
import hashlib
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

from agent_env.artifact.artifacts.computer import DISK_BASE_FILE, DISK_FILE, DISK_GUEST_FILE
Expand All @@ -17,11 +19,13 @@
from agent_env.env.published_images import LAUNCHER_IMAGE, published_pinned
from agent_env.providers.sandbox_providers.disk_launcher import launcher_variables
from agent_env.providers.sandbox_providers.host_images import host_platform, present
from agent_env.providers.sandbox_providers.local_docker.cli import docker
from agent_env.providers.sandbox_providers.local_docker.cli import DockerFailed, docker
from agent_env.store.local_state import holding_locks
from agent_env.utils.docker.build import build_image

KVM = "/dev/kvm"
#: What ``docker cp`` says of a path its container lacks: Docker 23 and later, then earlier.
_NO_SUCH_PATH = ("Could not find the file", "No such container:path")


def kvm_platforms() -> frozenset[str]:
Expand All @@ -35,62 +39,153 @@ def kvm_platforms() -> frozenset[str]:

async def machine_image(disk: str) -> str:
"""An image of the launcher holding ``disk``'s files for this machine's platform, built unless held: one for each
image ``disk`` names, by its id, so a disk built again under its tag gets a machine of its own. A disk of more than
one layer is a thin one on ``DISK_BASE_FILE``, which gets a step of its own, so every machine on that base shares
one copy of it."""
image ``disk`` names, by its id, so a disk built again under its tag gets a machine of its own. A thin disk's base
gets a step of its own, so every machine on that base shares one copy of it."""
source = await asyncio.to_thread(launcher)
identity, layers = (await asyncio.to_thread(docker, "image", "inspect", "-f", "{{.Id}} {{len .RootFS.Layers}}",
disk)).split()
tag = "agentenv-vm:" + hashlib.sha256(f"{source} {disk} {identity}".encode()).hexdigest()[:16]
layout = await asyncio.to_thread(_layout, disk)
tag = "agentenv-vm:" + hashlib.sha256(f"{source} {disk} {layout.identity}".encode()).hexdigest()[:16]
if not await asyncio.to_thread(present, tag):
layers = int(layers)
await asyncio.to_thread(_build, _machine_dockerfile(source, disk, layers), tag)
await asyncio.to_thread(_build, _machine_dockerfile(source, disk, thin=layout.base is not None), tag)
return tag


def _machine_dockerfile(launcher: str, disk: str, layers: int) -> str:
def _machine_dockerfile(launcher: str, disk: str, thin: bool) -> str:
folder = f"/{PurePosixPath(DISK_FILE).parent}/"
if layers == 1:
if not thin:
return f"FROM {launcher}\nCOPY --from={disk} {folder} {folder}\n"
return (f"FROM {launcher}\nCOPY --from={disk} /{DISK_BASE_FILE} /{DISK_BASE_FILE}\n"
f"COPY --from={disk} /{DISK_FILE} /{DISK_GUEST_FILE} {folder}\n")


@dataclass(frozen=True)
class _Layout:
"""A disk image's id and, for a thin disk, one of more than one layer on ``DISK_BASE_FILE``, its ``base``: the
digest of its first layer, the base's own, which every disk composed on that base shares; None for a whole one."""

identity: str
base: str | None


def _layout(disk: str) -> _Layout:
identity, layers, first = docker("image", "inspect", "-f",
"{{.Id}} {{len .RootFS.Layers}} {{index .RootFS.Layers 0}}", disk).split()
return _Layout(identity, first if int(layers) > 1 else None)


def launcher_args(ports: list[int], env: dict[str, str], cpu: float, memory: int) -> list[str]:
"""The ``docker run`` arguments that pass the launcher KVM and its ``launcher_variables``."""
return ["--device", KVM, *(arg for name, value in launcher_variables(ports, env, cpu, memory).items()
for arg in ("-e", f"{name}={value}"))]


def exported_disk(image: str, platform: str) -> Path:
"""``image``'s ``DISK_FILE`` compressed with zstd in this machine's cache, exported from its image store by this
machine's Docker unless an export of it is there already: once for each composed disk, by its ref, whose tag is its
composer's key. ``platform`` is the disk's own, which this machine's Docker may not be."""
path = cache_root() / "disks" / f"{hashlib.sha256(image.encode()).hexdigest()[:32]}.img.zst"
with holding_locks([f"disk-export:{path.name}"]):
if not path.exists():
_export(image, platform, path)
return path
@dataclass(frozen=True)
class Exported:
"""A disk exported for a provider elsewhere to carry: its ``DISK_FILE``, compressed; for a thin disk, its base,
compressed, exported once for each base; and its guest file, when it names one."""

disk: Path
base: Path | None = None
guest: Path | None = None


def exported_disk(image: str, platform: str) -> Exported:
"""``image``'s disk compressed with zstd in this machine's cache, exported from its image store by this machine's
Docker unless an export of it is there already: once for each composed disk, by its ref, whose tag is its composer's
key; a thin disk's base once for each base, by its layer, which every disk composed on it shares. ``platform`` is
the disk's own, which this machine's Docker may not be."""
cached = _Cached(cache_root() / "disks" / f"{hashlib.sha256(image.encode()).hexdigest()[:32]}.img.zst")
with holding_locks([f"disk-export:{cached.disk.name}"]):
if not cached.disk.exists():
_export(image, platform, cached)
return cached.exported()


@dataclass(frozen=True)
class _Cached:
"""A disk's export in this machine's cache, named for its image's ref: the ``disk``, compressed, and beside it its
guest file, a file naming its base's export, which every disk on that base shares, and the folder an export of it
works in."""

disk: Path

@property
def guest(self) -> Path:
return self._beside("guest")

def _export(image: str, platform: str, path: Path) -> None:
@property
def base_link(self) -> Path:
return self._beside("base")

@property
def work(self) -> Path:
return self._beside("work")

def base_of(self, layer: str) -> Path:
"""The export of the base whose first layer is ``layer``."""
return self.disk.with_name(f"base-{hashlib.sha256(layer.encode()).hexdigest()[:32]}.img.zst")

def exported(self) -> Exported:
base = self.disk.with_name(self.base_link.read_text()) if self.base_link.exists() else None
return Exported(self.disk, base, self.guest if self.guest.exists() else None)

def _beside(self, what: str) -> Path:
return self.disk.with_name(f"{self.disk.name}.{what}")


def _export(image: str, platform: str, cached: _Cached) -> None:
"""Signed in to the image store first, as a boot's pull is, since ``docker create`` pulls ``image`` when it isn't
here; for ``platform``, as the image's index holds that one alone, which a pull for this machine's wouldn't find."""
path.parent.mkdir(parents=True, exist_ok=True)
here; for ``platform``, as the image's index holds that one alone, which a pull for this machine's wouldn't find.
The disk is placed last, so one that's there was exported whole, with its base and guest file. Its work folder and
container are named for the export and cleared first, under its lock, so an export killed outright leaves no copy of
a base behind once the disk is exported again; the container by the folder's whole path, as another user's export
of the same disk through this machine's Docker holds a lock of its own."""
container = f"agentenv-export-{hashlib.sha256(str(cached.work).encode()).hexdigest()[:24]}"
_cleared(cached.work, container)
cached.work.mkdir(parents=True)
docker_login(get_config().get_image_store().auth(image))
container = docker("create", "--platform", platform, image, "disk")
docker("create", "--name", container, "--platform", platform, image, "disk")
try:
with tempfile.TemporaryDirectory(prefix="export-", dir=path.parent) as work:
raw, compressed = Path(work) / "disk.img", Path(work) / path.name
docker("cp", f"{container}:/{DISK_FILE}", str(raw))
with raw.open("rb") as source, compressed.open("wb") as out:
done = subprocess.run(["docker", "run", "--rm", "-i", "--entrypoint", "zstd", launcher(), "-q", "-3",
"-T0", "-c"], stdin=source, stdout=out, stderr=subprocess.PIPE, text=False)
if done.returncode:
raise RuntimeError(f"compressing {image}'s disk failed: {done.stderr.decode().strip()}")
os.replace(compressed, path)
if (guest := _copied(container, DISK_GUEST_FILE, cached.work / "guest")) is not None:
guest.replace(cached.guest)
if (layer := _layout(image).base) is not None:
base = cached.base_of(layer)
with holding_locks([f"disk-export:{base.name}"]):
if not base.exists():
_compressed(container, DISK_BASE_FILE, base, cached.work)
cached.base_link.write_text(base.name)
_compressed(container, DISK_FILE, cached.disk, cached.work)
finally:
subprocess.run(["docker", "rm", container], capture_output=True)
_cleared(cached.work, container)


def _cleared(work: Path, container: str) -> None:
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
shutil.rmtree(work, ignore_errors=True)


def _copied(container: str, file: str, to: Path) -> Path | None:
"""``file`` copied out of ``container`` to ``to``; None when the container has no such file. Any other failure is
raised, as an export cached without a guest file it has would boot it as the wrong OS."""
try:
docker("cp", f"{container}:/{file}", str(to))
except DockerFailed as e:
if not any(missing in str(e) for missing in _NO_SUCH_PATH):
raise
return None
return to


def _compressed(container: str, file: str, path: Path, work: Path) -> None:
"""``file`` of ``container`` compressed with zstd to ``path``, through ``work``."""
raw, compressed = work / PurePosixPath(file).name, work / path.name
docker("cp", f"{container}:/{file}", str(raw))
with raw.open("rb") as source, compressed.open("wb") as out:
done = subprocess.run(["docker", "run", "--rm", "-i", "--entrypoint", "zstd", launcher(), "-q", "-3", "-T0",
"-c"], stdin=source, stdout=out, stderr=subprocess.PIPE, text=False)
if done.returncode:
raise RuntimeError(f"compressing a disk's {file} failed: {done.stderr.decode().strip()}")
raw.unlink()
os.replace(compressed, path)


def launcher() -> str:
Expand Down
Loading
Loading