Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,10 @@ class MailApp(ComputerAppEnvironment):
installed into the venv every app gets beside them; `sources`, extra apt repositories
(`AptSource`, with a key file in the folder); `downloads`, files fetched by URL and checked by
sha256 (`Download`); `run`, scripts of the folder run as root or the session user (`RunScript`);
`services`, programs the computer's init runs from the prefix as the session user; and `ports`,
those it listens on. Packages, requirements and downloads are resolved and fetched once, when the
`services`, programs the computer's init runs from the prefix as the session user; `ports`,
those it listens on; and `shm_mb`, the shared memory it needs in `/dev/shm`, in MiB, which a
computer of it gets, the most its apps ask for, where its sandbox can size it (Chrome, say, which
keeps its renderers' memory there). Packages, requirements and downloads are resolved and fetched once, when the
computer env is put, and every build installs only those stored files, with the network closed.
Each value is checked as the class is defined: a URL, a sha256, a path that stays in the folder.
Installs are exact: apt installs each package without its recommends, and the venv gets exactly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ class Linux(BaseModel):
"""How a Linux computer installs the app: ``files``, a folder of the app's copied under its prefix; ``packages``,
the OS's; ``python``, a requirements file in that folder installed into a venv beside them; ``sources``, extra apt
repositories; ``downloads``, files fetched by URL; ``run``, scripts run once those are in; ``services``, programs
its init runs; ``ports``, those it listens on. Everything is fetched once at put, and builds install only that."""
its init runs; ``ports``, those it listens on; ``shm_mb``, the shared memory it needs in ``/dev/shm``, in MiB, which
a computer of it gets where its sandbox can size it. Everything is fetched once at put, and builds install only
that."""

model_config = ConfigDict(extra="forbid")

Expand All @@ -146,6 +148,7 @@ class Linux(BaseModel):
run: Optional[list[RunScript]] = None
services: list[Service] = Field(default_factory=list)
ports: Optional[list[int]] = None
shm_mb: Optional[int] = None


class Windows(BaseModel):
Expand Down Expand Up @@ -349,13 +352,13 @@ def deco(cls: type) -> type:
def linux(*, distribution: Optional[str] = None, files: str = ".", packages: Optional[Packages] = None,
python: Optional[str] = None, sources: Optional[list[AptSource]] = None,
downloads: Optional[list[Download]] = None, run: Optional[list[RunScript]] = None,
services: Optional[list[Service]] = None, ports: Optional[list[int]] = None, launch: Optional[Launch] = None,
paths: Optional[list[str]] = None, skill: Optional[str] = None, settings: Optional[type[BaseModel]] = None,
build_settings: Optional[type[BaseModel]] = None) -> Callable:
services: Optional[list[Service]] = None, ports: Optional[list[int]] = None, shm_mb: Optional[int] = None,
launch: Optional[Launch] = None, paths: Optional[list[str]] = None, skill: Optional[str] = None,
settings: Optional[type[BaseModel]] = None, build_settings: Optional[type[BaseModel]] = None) -> Callable:
"""``@install`` for Linux, or with ``distribution`` for that distribution alone: how such a computer installs the
app, ``Linux``'s fields, and what is its own there."""
spec = Linux(files=files, packages=packages, python=python, sources=sources, downloads=downloads, run=run,
services=services or [], ports=ports)
services=services or [], ports=ports, shm_mb=shm_mb)
return install(platform_key("linux", distribution), spec, launch=launch, paths=paths, skill=skill,
settings=settings, build_settings=build_settings)

Expand Down
4 changes: 3 additions & 1 deletion src/agent_env/browsers/envs/chromium/chromium.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
"unzip"]
WINDOW_CLASS = "Chromium"
PROFILE = ".config/chromium"
#: The shared memory, in MiB, Chrome's renderers get in /dev/shm, so a heavy page doesn't fill Docker's 64 MiB.
SHM_MB = 1024


@computer_app(supports=[{"os": {"type": "linux", "display": ["x11", "none"]}, "arch": ["amd64", "arm64"]}])
@linux(packages=Packages(apt=PACKAGES), downloads=DOWNLOADS, paths=[PROFILE],
@linux(packages=Packages(apt=PACKAGES), downloads=DOWNLOADS, paths=[PROFILE], shm_mb=SHM_MB,
launch=Launch(at="boot", command=["chromium/chrome/chrome", "--no-sandbox", f"--class={WINDOW_CLASS}"],
window={"class": WINDOW_CLASS}, ready=Ready(window=True)))
class Chromium(WebsiteBrowserEnvironment):
Expand Down
5 changes: 4 additions & 1 deletion src/agent_env/computers/disk/linux/boot/init
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ mount --move /sys /newroot/sys
mount --move /dev /newroot/dev
mkdir -p /newroot/dev/pts /newroot/dev/shm
mount -t devpts -o gid=5,mode=620,ptmxmode=666 devpts /newroot/dev/pts
mount -t tmpfs -o mode=1777,size=512m shm /newroot/dev/shm
# /dev/shm holds what the computer's apps need (AGENTENV_SHM_MB, in MiB) when the deploy says, else 512 MiB.
case "$AGENTENV_SHM_MB" in ''|0|*[!0-9]*) shm_mb=512 ;; *) shm_mb=$AGENTENV_SHM_MB ;; esac
unset AGENTENV_SHM_MB
mount -t tmpfs -o mode=1777,size="${shm_mb}m" shm /newroot/dev/shm
log "starting $* as $user ($uid:$gid) in $workdir"
# Last, as busybox runs its applets through /proc/self/exe.
mount --move /proc /newroot/proc
Expand Down
9 changes: 8 additions & 1 deletion src/agent_env/env/envs/computer/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from agent_env.env.envs.computer import COMPUTER_ENV_TYPE
from agent_env.env.envs.computer.utils import boot, composer, gate, inside, sites
from agent_env.env.envs.computer.utils.composed import stale
from agent_env.env.envs.computer.utils.install import platform_of
from agent_env.env.envs.computer.utils.install import for_os, platform_of
from agent_env.env.envs.computer.utils.composer import Composing
from agent_env.env.envs.computer.utils import params as computer_params
from agent_env.env.envs.computer.driver_env import ComputerDriverEnv
Expand Down Expand Up @@ -190,6 +190,13 @@ def composed_apps(self) -> list[ComputerAppEnv]:
"""The computer app envs composed into the env's computer, each installed under its prefix."""
return self.apps

def shm_mb(self) -> int | None:
"""The shared memory its computer's ``/dev/shm`` needs, in MiB: the most any of its apps' installs on its OS
declares, or None when none declares any."""
sizes = [size for app in self.composed_apps()
if (size := getattr(for_os(app.install, self.computer.os), "shm_mb", None))]
return max(sizes, default=None)

def app_defaults(self) -> dict[str, dict[str, Any]]:
"""Each composed app's default settings, by name: its own env's ``[settings]`` under this env's for it, field by
field."""
Expand Down
37 changes: 31 additions & 6 deletions src/agent_env/env/envs/computer/utils/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import asyncio
import contextlib
import json
import logging
import secrets
from collections.abc import Iterable
from dataclasses import dataclass
Expand All @@ -31,6 +32,8 @@
from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy, Sandbox
from agent_env.providers.sandbox_providers.sandbox_provider import SandboxProvider

logger = logging.getLogger(__name__)

#: How long a computer may take from its container starting to its driver saying it's ready.
READY_TIMEOUT_S = 180.0
#: How much longer than the slowest starting app's own readiness timeout a computer may take, for the rest of it first.
Expand All @@ -52,6 +55,8 @@ class ComputerFailed(RuntimeError):
#: for Windows, what Windows 11 asks of a PC.
DISK_CPU, DISK_MEMORY_MB = 2.0, 4096
WINDOWS_CPU, WINDOWS_MEMORY_MB = 4.0, 8192
#: The variable a Linux disk's init sizes its ``/dev/shm`` by, in MiB (``computers/disk/linux/boot/init``).
SHM_MB_ENV = "AGENTENV_SHM_MB"


@dataclass
Expand Down Expand Up @@ -150,16 +155,35 @@ def control_environment(token: Optional[str]) -> dict[str, str]:
return {CONTROL_TOKEN_ENV: token} if token else {}


def shm_environment(form: str, os_type: str, shm_mb: Optional[int]) -> dict[str, str]:
"""What sizes a Linux disk's ``/dev/shm`` to ``shm_mb`` in its boot environment, which its init reads; nothing for a
container, which its sandbox sizes, or without a size, which leaves the init's own."""
return {SHM_MB_ENV: str(shm_mb)} if shm_mb and form == "disk" and os_type == "linux" else {}


def shm_kwargs(sandbox_provider: SandboxProvider, shm_mb: Optional[int]) -> dict[str, int]:
"""What ``create_container`` and ``reset_container`` take for the container's ``/dev/shm`` to hold ``shm_mb`` MiB:
the size for a provider whose SHARED_MEMORY says it gives that, else nothing, logged, the container keeping its
provider's own."""
if not shm_mb:
return {}
if sandbox_provider.SHARED_MEMORY:
return {"shm_mb": shm_mb}
logger.info("%s can't size /dev/shm: the container keeps its default, not the %d MiB asked for",
type(sandbox_provider).__name__, shm_mb)
return {}


async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, environment: dict[str, str], token: str,
network_policy: Optional[NetworkPolicy], timeout: int, attribution: Optional[Attribution],
disk_size_gb: float = 10, cpu: Optional[float] = None, memory_mb: Optional[int] = None,
ready_s: float = READY_TIMEOUT_S, form: str = "container", held: bool = False,
os_type: str = "linux") -> Booted:
os_type: str = "linux", shm_mb: Optional[int] = None) -> Booted:
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
"""``image``, of ``form``, booted with ``environment`` and its control ``token`` on ``sandbox_provider``, which the
caller chose to run its arch natively, its driver's ``port`` reached on the sandbox, once the driver, asked with that
token, says the computer is ready, or with ``held``, its boot apps' launch held, once it answers (``ready``); a boot
that fails is torn down."""
environment = {**environment, **control_environment(token)}
caller chose to run its arch natively, its driver's ``port`` reached on the sandbox, its ``/dev/shm`` ``shm_mb`` MiB
where the sandbox can size it, once the driver, asked with that token, says the computer is ready, or with
``held``, its boot apps' launch held, once it answers (``ready``); a boot that fails is torn down."""
environment = {**environment, **control_environment(token), **shm_environment(form, os_type, shm_mb)}
if form == "vm_bundle":
sandbox = await sandbox_provider.create_vm(
image=image, env=environment, exposed_ports=[port], network_policy=network_policy,
Expand All @@ -173,7 +197,8 @@ async def boot(sandbox_provider: SandboxProvider, *, image: str, port: int, envi
else:
sandbox = await sandbox_provider.create_container(
image_name=image, port=port, env=environment, network_policy=network_policy, disk_size_gb=disk_size_gb,
timeout=timeout, attribution=dict(attribution or {}), **_size_kwargs(cpu, memory_mb))
timeout=timeout, attribution=dict(attribution or {}), **_size_kwargs(cpu, memory_mb),
**shm_kwargs(sandbox_provider, shm_mb))
try:
url = sandbox.tunnel_urls[port]
return Booted(sandbox, url, await ready(url, ready_s, token, answering=held))
Expand Down
8 changes: 5 additions & 3 deletions src/agent_env/env/envs/computer/utils/composed.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ def built(self, form: str = "container") -> dict[str, str]:
if result.get("status") in BUILT and not stale(result)}

def computer(self, ref: dict | None) -> None:
"""Record the env's computer, with the images it was written over, the disks' and the VM bundles' apart."""
"""Record the env's computer, with the images it was written over, the disks' and the VM bundles' apart, and the
shared memory its apps need."""
if ref is not None:
with self.lock:
disks, bundles = self.built("disk"), self.built("vm_bundle")
disks, bundles, shm_mb = self.built("disk"), self.built("vm_bundle"), self.env.shm_mb()
self.env.merge_metadata({"computer": {**ref, "images": self.built(), **({"disks": disks} if disks else {}),
**({"vm_bundles": bundles} if bundles else {})}})
**({"vm_bundles": bundles} if bundles else {}),
**({"shm_mb": shm_mb} if shm_mb else {})}})

def _merge(self, results: dict[tuple[str, str], dict]) -> None:
with self.lock:
Expand Down
18 changes: 15 additions & 3 deletions src/agent_env/env/envs/computer/utils/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ class with ``@linux(...)``: ``files``, a folder of its own copied under the laye
packages, as apt takes them; ``python``, a requirements file in that folder installed into a venv beside them;
``sources``, extra apt repositories to resolve from; ``downloads``, files fetched by URL and checked by sha256;
``run``, scripts of the folder run once those are installed; ``services``, the programs the base's init runs from the
prefix; and ``ports``, those it listens on. Everything but the files is resolved and fetched once at put, and every
build installs only what was stored then (``packages`` module). The models of packages, sources, downloads and run
prefix; and ``ports``, those it listens on. An app's Linux install also takes ``shm_mb``, the shared memory it needs in
``/dev/shm``, in MiB. Everything but the files is resolved and fetched once at put, and every build installs only what
was stored then (``packages`` module). The models of packages, sources, downloads and run
scripts are the protocol's, which an app's ``@linux(...)`` uses too. ``[install.macos]`` takes the same, but apt's
packages and sources; its services are LaunchAgents of the login session, and its ``grants`` the TCC permissions an app
bundle or an executable it installs needs. ``[install.windows]`` takes the same as macOS but grants: its run scripts are
Expand All @@ -24,6 +25,8 @@ class with ``@linux(...)``: ``files``, a folder of its own copied under the laye

#: The highest port a layer can listen on.
MAX_PORT = 65535
#: The most shared memory, in MiB, an app can ask its computer's ``/dev/shm`` for.
MAX_SHM_MB = 65536
# A requirements line that includes another requirements or constraints file, or installs one editable.
_INCLUDES = re.compile(r"-[rce]|--(requirement|constraint|editable)(=|$)")
_FIND_LINKS = re.compile(r"-f|--find-links(=|$)")
Expand Down Expand Up @@ -118,9 +121,18 @@ def _named_once(cls, value: list[Service]) -> list[Service]:


class AppInstall(Install):
"""An app's install, which may run no service: a desktop app runs when it's launched."""
"""An app's install, which may run no service: a desktop app runs when it's launched. ``shm_mb`` is the shared
memory it needs in ``/dev/shm``, a Linux computer's alone."""

services: list[Service] = Field(default_factory=list)
shm_mb: int | None = None

@field_validator("shm_mb")
@classmethod
def _shm_mb(cls, value: int | None) -> int | None:
if value is not None and not 1 <= value <= MAX_SHM_MB:
raise ValueError(f"shm_mb is 1 to {MAX_SHM_MB} MiB, not {value}")
return value


class MacInstall(Install):
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/env/envs/computer/utils/probing.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ async def _probe(links: list[SandboxProvider], env: ComputerEnv, driver: Compute
booted = await boot.boot(link, image=image, port=driver.port, environment=environment, token=token,
network_policy=None, timeout=_PROBE_TTL_S, attribution=None,
ready_s=boot.ready_timeout(env.composed_apps(), platform_of(env.computer.os)),
form=form, os_type=env.computer.os.type)
form=form, os_type=env.computer.os.type, shm_mb=env.shm_mb())
except Exception as e:
problems.append(f"{type(link).__name__}: {e}")
continue
Expand Down
12 changes: 9 additions & 3 deletions src/agent_env/providers/env_providers/env_computer_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
recorded_app_settings,
recorded_settings,
settings_environment,
shm_environment,
shm_kwargs,
)
from agent_env.env.gateway.constants import WELL_KNOWN_PATH
from agent_env.providers.env_providers.env_provider import DeployRefused, _SandboxEnvironmentProvider
Expand Down Expand Up @@ -167,14 +169,15 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par
app_settings = recorded_app_settings(env.composed_apps(), computer_params.app_settings(env, params.app_settings),
platform_of(env.computer.os))
policy = _reaching(NetworkPolicy.from_dict(env.network) if env.network else None, sandbox_provider, reach)
shm_mb = env.shm_mb()
booted = await boot(
sandbox_provider, image=build["image"], port=env.driver.port, network_policy=policy, token=token,
environment=_environment(env, computer, values, params, tool_prefix, sites_later=sites_later),
timeout=ttl_seconds,
attribution=attribution,
disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb,
ready_s=ready_timeout(env.composed_apps(), platform_of(env.computer.os)), form=form,
held=bool(params.site_routing), os_type=env.computer.os.type)
held=bool(params.site_routing), os_type=env.computer.os.type, shm_mb=shm_mb)
self._container_sandboxes.append(booted.sandbox)
self._environment_sandboxes[name] = booted.sandbox
try:
Expand All @@ -190,6 +193,7 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par
"computer": {"id": env.metadata["computer"]["id"], "version": env.metadata["computer"]["version"]},
"port": env.driver.port, "display": values.get("resolution"), "network": env.network,
"computer_settings": recorded_settings(computer, values),
**({"shm_mb": shm_mb} if shm_mb else {}),
**({"app_settings": app_settings} if app_settings else {}),
**({"tool_prefix": tool_prefix} if tool_prefix else {}),
"env_params": params.model_dump(exclude_defaults=True)}},
Expand All @@ -214,14 +218,16 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None:
await end_recordings(deployed, "reset")
sandbox_provider = build_sandbox_provider(deployed.sandbox_type)
sandbox = await sandbox_provider.get_sandbox(deployed.sandbox_id)
shm_mb = record.get("shm_mb")
environment = {**_environment(env, computer, values, params, record.get("tool_prefix", "")),
**control_environment(deployed.control_token)}
**control_environment(deployed.control_token),
**shm_environment(record.get("form", "container"), env.computer.os.type, shm_mb)}
try:
if record.get("form") in ("disk", "vm_bundle"):
await sandbox_provider.reset_vm(sandbox, image=record["image"], env=environment)
else:
await sandbox_provider.reset_container(sandbox, image_name=record["image"], port=env.driver.port,
env=environment)
env=environment, **shm_kwargs(sandbox_provider, shm_mb))
except NotImplementedError as e: # a reset the env supports, which this provider can't carry out
raise RuntimeError(f"'{env.id}' can't be reset on {type(sandbox_provider).__name__}: {e}") from None
card = await ready(deployed.environment_url, ready_timeout(env.composed_apps(), platform_of(env.computer.os)),
Expand Down
Loading
Loading