From 7af7de51528294d23c93303d1ff4bee8881bd862 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 10:03:17 -0800 Subject: [PATCH 1/2] feat(providers): a sandbox provider declares WEBSOCKETS, and a computer with a website browser refuses one without it A website browser's CDP address is a WebSocket, so on a provider whose tunnel doesn't carry one the deploy succeeded and every CDP client then failed. Providers now declare WEBSOCKETS: on for local, smol_vm, lume, modal, modal_vm, boxd, sail_vm, tenki and agent37, where our tests or their tunnels' docs say so, and off for the six M8.25 checks live. A computer env with a website browser among its apps refuses at preflight when no link that runs it natively carries WebSockets, naming them, and a chain's deploy skips such a link for the next. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/agent_env/env/envs/computer/env.py | 16 ++++++-- .../env_providers/env_computer_provider.py | 3 ++ .../providers/sandbox_providers/AGENTS.md | 3 +- .../sandbox_providers/agent37/provider.py | 1 + .../sandbox_providers/boxd/provider.py | 1 + .../local_docker/provider.py | 1 + .../sandbox_providers/lume/provider.py | 1 + .../sandbox_providers/modal_sandbox.py | 1 + .../sandbox_providers/modal_vm_sandbox.py | 1 + .../sandbox_providers/sail_vm/provider.py | 1 + .../sandbox_providers/sandbox_provider.py | 3 ++ .../sandbox_providers/smol_vm/provider.py | 1 + .../sandbox_providers/tenki/provider.py | 1 + tst/unit/env/envs/test_computer_env.py | 31 ++++++++++++++-- .../provider_capabilities_test.py | 37 ++++++++++--------- 16 files changed, 77 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3c3241cf..c0eae13a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ and deserialized through a registry. | `task/`, `task_step/` | A `Task` holds its `TaskStep`s inline; `Task.run()` executes them as a DAG. `depends_on` (None means all prior steps) gates a step, independent steps run concurrently, `fail_task_on_error` makes a failure fatal or tolerated, `retry_config` rolls a failed span back through the step journal and re-dispatches it. Built-in steps live in `task_step/task_steps/` (`deploy_env`, `deploy_agent`, `prompt_agent`, the verifiers under `verifiers/`, among them `start_computer_recording` and `end_computer_recording`, and more). On an env whose card serves computer-exec/v1 (a computer), `run_code` with `env_id` and `run_container_unit_tests_verifier` with `env_id` run inside it as its session user through the driver: files staged with `data/add` and read with `data/get`, commands run with `run`; `env_outcome_verifier` hands a `verify` that takes an `env_url` keyword the env's address. A computer's framework-only routes (everything but MCP and the card) take the per-deploy control token its provider boots it with (`DeployedEnv.control_token`, `AGENTENV_CONTROL_TOKEN`); framework calls send `deployed.control_headers`, `Task.run` runs every step under `protocol_v1.authorized` with each deployed env's (a `verify` included), and a `run_code` script inside the computer gets `input["env"]["headers"]`. `teardown_run` and the `teardown_sandboxes` step share one core (`task/teardown.py` `tear_down`): the recordings the run left open end with fresh grants, each env still up closes (`Env.close`; a computer env ends any recording still open), what stayed open is registered from its namespace, then every sandbox is terminated by id. `task/episode/` runs a task with some of its agents played from outside, as a training loop does (`EpisodeSpec`). | | `store/` | Four store ABCs with local and cloud implementations: `DocumentStore` (SQLite, MongoDB, DynamoDB, Firestore with MongoDB compatibility), `ObjectStore` (filesystem, S3, Cloud Storage), `ImageStore` (local OCI registry, ECR), `SecretStore` (env vars or file, AWS Secrets Manager, Google Cloud Secret Manager). `VersionedEntityStore` implements the shared versioned get/put logic, `QueryBuilder` is the immutable chained query API, `store/base.py` holds the error types. `store/routing.py` is the local namespaces, `@local` and `@hf` (`is_local_id`), each with a documents file of its own: while the CLI or a `build_app` server runs, a local id and what a local task's run records go to local stores beside the configured ones, and that run's writes to the configured stores are refused. A new backend must pass the conformance kits in `tst/store/`. | | `config/` | The `Config` singleton (`get_config`, `configure`, `reset_config`) in `config/runtime.py`, file discovery in `config/loader.py`, and `load_impl`, which resolves `module:Class` pointers. `agent_env.store` re-exports the config names for compatibility. | -| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra), `agent37` (no extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `providers/env_state/` holds env-state providers (`local_postgres` built in). | +| `providers/` | `providers/sandbox_providers/` holds the sandbox providers `local`, `modal`, `modal_vm`, `e2b`, `sail_vm` (the `sail` extra), `smol_vm` (the `smol` extra), `tensorlake` (the `tensorlake` extra), `tenki` (the `tenki` extra), `vercel` (the `vercel` extra), `boxd` (the `boxd` extra), `coreweave` (the `coreweave` extra), `daytona` (the `daytona` extra), `boat` (the `boat` extra), `agent37` (no extra); `[sandbox] default` and `agent_default` accept a comma-separated fallback chain, whose deploys skip a provider that can't run them. A sandbox provider declares what it runs (`ON_THIS_MACHINE`, and for VMs on this machine the address their requests to it arrive at, `REACHES_HOST_AT`; `CONTAINER_ACCEPTS`, `PRIVATE_NETWORK`, `WEBSOCKETS`; `SUPPORTED_SANDBOX_MODES`, derived from whether it implements `create_sandbox` and `create_vm`; and `SANDBOX_ACCEPTS`, the images an agent's sandbox runs, which defaults to those a VM loads when it implements `create_vm` and to `CONTAINER_ACCEPTS` otherwise, so only a provider that implements `create_vm` yet gives an agent a container, like the local one, declares it), each sandbox carries the `platform` of the images it runs (linux/amd64 unless its provider sets another when it creates it; None, any, unchecked, for a local sandbox), and code outside `providers/sandbox_providers/` reads those, never which provider it has. `providers/env_providers/` holds the environment providers: `EnvironmentProvider` (an env's containers and state store), `EnvironmentGatewayProvider` (`gateway`, an env's default), which renders a docker-compose for the gateway and its MCP servers inside the sandbox, and `EnvironmentServerProvider` (`server`), one MCP server in its own container with no gateway in front and no state store; `providers/env_state/` holds env-state providers (`local_postgres` built in). | | `a2a_agent/` | The `A2AAgent` entity (`a2a_agent`), its stores and the validator steps. The protocol package provides the agent-side framework. | | `openenv/` | `build_app(bundle)`: the [OpenEnv](https://github.com/openenv/OpenEnv) server for a bundle's tasks, each session playing one episode through the Episode API, with the envs' MCP tools, an added `end_turn` tool and the task's verifier score as the reward. It prepares bundles as `agent-env run` does (`prepare_bundle(played=True)`, without the agent the client plays) and keeps namespace routing on for the app's life. `openenv` comes with the `openenv` extra, a floor with no cap; this package and `training/` are the only ones that import it. Each task's description in the Task API names its `toolset` (the played agent's envs, role, and whether it gets `bash`). With `token`, every request and WebSocket but `/health` must carry `Authorization: Bearer `, for a server others reach. | | `training/` | One module per outside trainer, each behind its own extra, and the modules they share; [Training helpers](#training-helpers) lists them. | diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 2ec41575d..2089e12f1 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -161,6 +161,10 @@ def accept_toml(cls, data: dict, ctx: AuthoringContext) -> dict: ctx.refuse([*refused, *missing]) return {"environment_name": ctx.name, **fields} + def serves_website_browser(self) -> bool: + """Whether a website browser is among its apps, whose CDP address an agent reaches by WebSocket.""" + return any(app.declaration.browser for app in self.composed_apps()) + def composed_apps(self) -> list[ComputerAppEnv]: """The computer app envs composed into the env's computer, each installed under its prefix.""" return self.apps @@ -255,8 +259,8 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optional[dict] = None, **options) -> str | None: """Why deploy() would refuse before booting anything, or None: an option the computer provider doesn't take, a - problem with ``env_params``, or a sandbox provider that runs none of the env's arches natively or can't enforce - its network.""" + problem with ``env_params``, or a sandbox provider that runs none of the env's arches natively, carries no + WebSockets to a website browser among its apps where it does, or can't enforce its network.""" from agent_env.providers import build_sandbox_provider, get_env_sandbox_provider from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy @@ -266,8 +270,12 @@ def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optiona sandbox_provider = build_sandbox_provider(sandbox_type) if sandbox_type else get_env_sandbox_provider() links, arches = sandbox_provider.links, self.composed_arches() forms = [params.form] if params.form else gate.forms(self.computer) - if not any(boot.native_links(sandbox_provider, arch, form, self.stored_id) for form in forms - for arch in arches): + native = [link for link in links if any(boot.native_links(link, arch, form, self.stored_id) for form in forms + for arch in arches)] + if native and self.serves_website_browser() and not any(link.WEBSOCKETS for link in native): + problems.append(f"{', '.join(type(link).__name__ for link in native)} carries no WebSockets, which its " + "website browser's CDP address needs") + if not native: unpulled = [type(link).__name__ for link in links if not boot.pulls_containers(link, self.stored_id)] problems.append(f"{', '.join(type(link).__name__ for link in links)} runs none of its arches " f"({', '.join(arches)}) natively as {' or '.join(forms)}" + ( diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index 7b31788fa..b4a222cd1 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -99,6 +99,9 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par if (picked := first_native(sandbox_provider, pairs, env.stored_id)) is None: raise ValueError(f"{type(sandbox_provider).__name__} runs none of {env.id}'s ready builds natively " f"({', '.join(f'{form}/{arch}' for form, arch, _ in builds)})") + if env.serves_website_browser() and not sandbox_provider.WEBSOCKETS: + raise ValueError(f"{type(sandbox_provider).__name__} carries no WebSockets, which {env.id}'s website " + "browser's CDP address needs") form, arch = picked build = next(build for name, at, build in builds if (name, at) == picked) if refusal := env.disk_size_refusal(form, disk_size_gb): diff --git a/src/agent_env/providers/sandbox_providers/AGENTS.md b/src/agent_env/providers/sandbox_providers/AGENTS.md index 187869bca..e2de947c4 100644 --- a/src/agent_env/providers/sandbox_providers/AGENTS.md +++ b/src/agent_env/providers/sandbox_providers/AGENTS.md @@ -78,7 +78,7 @@ with `exposed_ports=[port]` and returns the VM, and the deploy then loads and st ### Declarations Class variables the rest of agent-env reads instead of asking which provider it has. A provider that declares nothing has its -sandboxes off this machine, runs containers' images by name, and has no private network. +sandboxes off this machine, runs containers' images by name, has no private network, and isn't trusted to carry WebSockets. | Variable | Set it when | |---|---| @@ -86,6 +86,7 @@ sandboxes off this machine, runs containers' images by name, and has no private | `CONTAINER_ACCEPTS` | `create_container` runs images other than by name: `Accepts.LOADABLE` (tar.gz, registry name or build context), `Accepts.NAME_OR_CONTEXT` | | `SANDBOX_ACCEPTS` | an agent's sandbox runs different images than the default (a provider that implements `create_vm` defaults to `LOADABLE`) | | `PRIVATE_NETWORK` | `create_container(private_network=True)` puts containers on a network they share | +| `WEBSOCKETS` | its sandboxes' exposed ports carry WebSockets both ways, as our own tests or its tunnel's documentation show; a computer with a website browser among its apps, whose CDP address agents reach by WebSocket, refuses a provider without it at preflight, and a chain skips it at deploy | ## Sandbox handle contract diff --git a/src/agent_env/providers/sandbox_providers/agent37/provider.py b/src/agent_env/providers/sandbox_providers/agent37/provider.py index 51b7e699e..597a743f6 100644 --- a/src/agent_env/providers/sandbox_providers/agent37/provider.py +++ b/src/agent_env/providers/sandbox_providers/agent37/provider.py @@ -80,6 +80,7 @@ class Agent37SandboxProvider(SandboxProvider): # Exposed ports get public URLs under this domain. EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.agent37.app",) + WEBSOCKETS = True def __init__(self, *, api_key: str, region: str = "us", transport: httpx.AsyncBaseTransport | None = None): if not isinstance(api_key, str) or not api_key.strip(): diff --git a/src/agent_env/providers/sandbox_providers/boxd/provider.py b/src/agent_env/providers/sandbox_providers/boxd/provider.py index f5497a80a..07092c707 100644 --- a/src/agent_env/providers/sandbox_providers/boxd/provider.py +++ b/src/agent_env/providers/sandbox_providers/boxd/provider.py @@ -188,6 +188,7 @@ class BoxdSandboxProvider(SandboxProvider): # Exposed ports get public URLs under this domain. EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.boxd.sh",) + WEBSOCKETS = True def __init__( self, diff --git a/src/agent_env/providers/sandbox_providers/local_docker/provider.py b/src/agent_env/providers/sandbox_providers/local_docker/provider.py index 590e0baad..4196521b1 100644 --- a/src/agent_env/providers/sandbox_providers/local_docker/provider.py +++ b/src/agent_env/providers/sandbox_providers/local_docker/provider.py @@ -648,6 +648,7 @@ class LocalSandboxProvider(SandboxProvider): # a VM it creates (create_vm) loads images. SANDBOX_ACCEPTS = Accepts.NAME_OR_CONTEXT CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT + WEBSOCKETS = True @property def _prepared(self) -> set[str]: diff --git a/src/agent_env/providers/sandbox_providers/lume/provider.py b/src/agent_env/providers/sandbox_providers/lume/provider.py index 70cdaa827..f1e2da52f 100644 --- a/src/agent_env/providers/sandbox_providers/lume/provider.py +++ b/src/agent_env/providers/sandbox_providers/lume/provider.py @@ -120,6 +120,7 @@ class LumeSandboxProvider(SandboxProvider): ON_THIS_MACHINE: ClassVar[bool] = True REACHES_HOST_AT: ClassVar[str] = "127.0.0.1" + WEBSOCKETS = True def __init__(self, *, ssh_user: str = "lume", ssh_password: str = "lume", max_running: int = MAX_RUNNING, relay_host: str = "127.0.0.1", lume: str = "lume", ssh: str = "/usr/bin/ssh", diff --git a/src/agent_env/providers/sandbox_providers/modal_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_sandbox.py index 2b91ca42a..02c524925 100644 --- a/src/agent_env/providers/sandbox_providers/modal_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_sandbox.py @@ -232,6 +232,7 @@ class ModalSandboxProvider(SandboxProvider): # containers share i6pn, Modal's private network. CONTAINER_ACCEPTS = Accepts.NAME_OR_CONTEXT PRIVATE_NETWORK = True + WEBSOCKETS = True @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: diff --git a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py index 03e52d698..58d1ecb92 100644 --- a/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py +++ b/src/agent_env/providers/sandbox_providers/modal_vm_sandbox.py @@ -292,6 +292,7 @@ class ModalVmSandboxProvider(SandboxProvider): """Provisions a single Modal VM sandbox per env (the VM-mode Modal backend), or one booting a computer's disk.""" EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.modal.host", "*.w.modal.host") + WEBSOCKETS = True @classmethod def supports_network_policy(cls, policy: NetworkPolicy) -> bool: diff --git a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py index 24dfd5e1d..88b6be959 100644 --- a/src/agent_env/providers/sandbox_providers/sail_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/sail_vm/provider.py @@ -133,6 +133,7 @@ class SailVmSandboxProvider(SandboxProvider): Sail adds it to the agent's requests to the model endpoint (see ``model_key``).""" EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sail.box",) + WEBSOCKETS = True def __init__( self, diff --git a/src/agent_env/providers/sandbox_providers/sandbox_provider.py b/src/agent_env/providers/sandbox_providers/sandbox_provider.py index b5c228ab4..e745393b3 100644 --- a/src/agent_env/providers/sandbox_providers/sandbox_provider.py +++ b/src/agent_env/providers/sandbox_providers/sandbox_provider.py @@ -237,6 +237,9 @@ def from_config(cls, **config: Any) -> Self: _DECLARES_SANDBOX_ACCEPTS: ClassVar[bool] = False # Whether create_container(private_network=True) puts containers on a network they share. PRIVATE_NETWORK: ClassVar[bool] = False + # Whether its sandboxes' exposed ports carry WebSockets both ways, as a website browser's CDP address needs: on + # where our own tests or its tunnel's documentation say so. + WEBSOCKETS: ClassVar[bool] = False def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) diff --git a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py index 85fec7066..0cad68694 100644 --- a/src/agent_env/providers/sandbox_providers/smol_vm/provider.py +++ b/src/agent_env/providers/sandbox_providers/smol_vm/provider.py @@ -69,6 +69,7 @@ class SmolVmSandboxProvider(SandboxProvider): _LOCAL_REGISTRY_DEFAULT_PORT: ClassVar[int] = 5000 ON_THIS_MACHINE: ClassVar[bool] = True REACHES_HOST_AT: ClassVar[str] = "127.0.0.1" + WEBSOCKETS = True CREATES_VMS: ClassVar[bool] = True SANDBOX_ACCEPTS: ClassVar[Accepts] = Accepts.LOADABLE CONTAINER_ACCEPTS: ClassVar[Accepts] = Accepts.NAME diff --git a/src/agent_env/providers/sandbox_providers/tenki/provider.py b/src/agent_env/providers/sandbox_providers/tenki/provider.py index a52d5ce0a..894ef7152 100644 --- a/src/agent_env/providers/sandbox_providers/tenki/provider.py +++ b/src/agent_env/providers/sandbox_providers/tenki/provider.py @@ -191,6 +191,7 @@ class TenkiSandboxProvider(SandboxProvider): # Exposed ports get public URLs under these domains. Tenki wildcards cover one label, hence the regional entry. EGRESS_HOSTS: ClassVar[tuple[str, ...]] = ("*.sb.tenki.sh", "*.us.sb.tenki.sh") + WEBSOCKETS = True def __init__( self, diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index cc66c6d82..ac174d94d 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -47,7 +47,9 @@ from agent_env.env.envs.computer.utils.install import Install, Service from agent_env.env.envs.computer.driver_env import ComputerDriverEnv, KnownFailure from agent_env.plugins._bundles import ALIASES_FILE, InstalledBundle, installed_aliases +from agent_env import providers from agent_env.providers.env_providers import env_computer_provider +from agent_env.providers.sandbox_providers.chained_sandbox_provider import ChainedSandboxProvider from agent_env.providers.env_providers.env_provider import DeployRefused from agent_env.task_step.task_steps.deploy_env import DeployEnvTaskStep from agent_env.task_step.task_steps.reset_env import ResetEnvTaskStep @@ -891,18 +893,41 @@ def test_a_boot_mints_a_generated_setting_resolves_a_secret_its_record_withholds @pytest.mark.parametrize("change, problem", [ ("riscv64", "LocalSandboxProvider runs none of its arches (amd64, arm64) natively"), ("network", "LocalSandboxProvider can't enforce its network"), + ("websockets", "LocalSandboxProvider carries no WebSockets, which its website browser's CDP address needs"), ]) -def test_a_deploy_its_sandbox_provider_cant_carry_out_is_refused_at_save(base, driver, docker, monkeypatch, change, - problem): - env = _put(base, driver, network={"mode": "allowlist", "allow_hosts": ["example.com"]} if change == "network" else None) +def test_a_deploy_its_sandbox_provider_cant_carry_out_is_refused_at_save(base, driver, docker, monkeypatch, tmp_path, + change, problem): + browser = [_app(None, None, tmp_path / "chromium", compose=False, name="chromium", browser=True)] + env = _put(base, driver, network={"mode": "allowlist", "allow_hosts": ["example.com"]} if change == "network" else None, + apps=browser if change == "websockets" else []) if change == "riscv64": monkeypatch.setattr(local_sandbox, "host_platform", lambda: "linux/riscv64") + if change == "websockets": + assert DeployEnvTaskStep(id="d", version=None, env_id=env.id).preflight() == [] + monkeypatch.setattr(local_sandbox.LocalSandboxProvider, "WEBSOCKETS", False) (refusal,) = DeployEnvTaskStep(id="d", version=None, env_id=env.id).preflight() assert problem in refusal +@pytest.mark.asyncio +async def test_a_deploy_with_a_website_browser_skips_a_link_that_carries_no_websockets_and_boots_on_the_next( + base, driver, docker, boots, monkeypatch, tmp_path): + env = _put(base, driver, display={"width": 1600, "height": 900}, + apps=[_app(None, None, tmp_path / "chromium", compose=False, name="chromium", browser=True)]) + + class NoWebSockets(local_sandbox.LocalSandboxProvider): + WEBSOCKETS = False + + chain = ChainedSandboxProvider([NoWebSockets(), local_sandbox.LocalSandboxProvider()]) + monkeypatch.setattr(providers, "build_sandbox_provider", lambda spec: chain) + + await Env.get(env.id).deploy(sandbox_type="chain") + + assert [made["provider"] for made in boots.boots] == ["LocalSandboxProvider"] + + @pytest.fixture def computer(tmp_path, monkeypatch): (tmp_path / "home").mkdir() diff --git a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py index 8cf3d5744..4f21e8f88 100644 --- a/tst/unit/providers/sandbox_providers/provider_capabilities_test.py +++ b/tst/unit/providers/sandbox_providers/provider_capabilities_test.py @@ -1,6 +1,6 @@ """What each sandbox provider declares it runs, and that nothing outside the providers asks which provider it has: every choice that depends on a provider reads what it declares (``ON_THIS_MACHINE``, ``SUPPORTED_SANDBOX_MODES``, -``SANDBOX_ACCEPTS``, ``CONTAINER_ACCEPTS``, ``PRIVATE_NETWORK``, ``links`` and ``url_from_sandbox``) and its sandboxes' +``SANDBOX_ACCEPTS``, ``CONTAINER_ACCEPTS``, ``PRIVATE_NETWORK``, ``WEBSOCKETS``, ``links`` and ``url_from_sandbox``) and its sandboxes' ``private_host`` and ``platform``.""" import ast @@ -52,25 +52,25 @@ ChainedSandboxProvider] -@pytest.mark.parametrize("cls, on_this_machine, creates_vms, sandbox, container, private_network", [ - (LocalSandboxProvider, True, True, NAME_OR_CONTEXT, NAME_OR_CONTEXT, False), - (ModalSandboxProvider, False, False, NAME_OR_CONTEXT, NAME_OR_CONTEXT, True), - (ModalVmSandboxProvider, False, True, LOADABLE, NAME, False), - (E2BSandboxProvider, False, True, LOADABLE, NAME, False), - (SailVmSandboxProvider, False, True, LOADABLE, NAME, False), - (TensorlakeSandboxProvider, False, True, LOADABLE, NAME, False), - (TenkiSandboxProvider, False, True, LOADABLE, NAME, False), - (BoxdSandboxProvider, False, True, LOADABLE, NAME, False), - (CoreWeaveSandboxProvider, False, True, NAME, NAME, False), - (DaytonaSandboxProvider, False, True, LOADABLE, NAME, False), - (BoatSandboxProvider, False, True, LOADABLE, NAME, False), - (Agent37SandboxProvider, False, True, LOADABLE, NAME, False), +@pytest.mark.parametrize("cls, on_this_machine, creates_vms, sandbox, container, private_network, websockets", [ + (LocalSandboxProvider, True, True, NAME_OR_CONTEXT, NAME_OR_CONTEXT, False, True), + (ModalSandboxProvider, False, False, NAME_OR_CONTEXT, NAME_OR_CONTEXT, True, True), + (ModalVmSandboxProvider, False, True, LOADABLE, NAME, False, True), + (E2BSandboxProvider, False, True, LOADABLE, NAME, False, False), + (SailVmSandboxProvider, False, True, LOADABLE, NAME, False, True), + (TensorlakeSandboxProvider, False, True, LOADABLE, NAME, False, False), + (TenkiSandboxProvider, False, True, LOADABLE, NAME, False, True), + (BoxdSandboxProvider, False, True, LOADABLE, NAME, False, True), + (CoreWeaveSandboxProvider, False, True, NAME, NAME, False, False), + (DaytonaSandboxProvider, False, True, LOADABLE, NAME, False, False), + (BoatSandboxProvider, False, True, LOADABLE, NAME, False, False), + (Agent37SandboxProvider, False, True, LOADABLE, NAME, False, True), ], ids=["local", "modal", "modal_vm", "e2b", "sail_vm", "tensorlake", "tenki", "boxd", "coreweave", "daytona", "boat", "agent37"]) def test_each_provider_declares_where_it_runs_and_the_images_it_runs(cls, on_this_machine, creates_vms, sandbox, - container, private_network): - assert ((cls.ON_THIS_MACHINE, SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES, cls.PRIVATE_NETWORK) - == (on_this_machine, creates_vms, private_network)) + container, private_network, websockets): + assert ((cls.ON_THIS_MACHINE, SANDBOX_MODE_VM in cls.SUPPORTED_SANDBOX_MODES, cls.PRIVATE_NETWORK, cls.WEBSOCKETS) + == (on_this_machine, creates_vms, private_network, websockets)) assert (cls.SANDBOX_ACCEPTS, cls.CONTAINER_ACCEPTS) == (sandbox, container) @@ -90,7 +90,8 @@ async def create_vm(self, **_): def test_a_provider_that_declares_nothing_has_what_it_implements_and_loads_an_agents_image_on_a_vm(): - assert (_Vms.ON_THIS_MACHINE, _Vms.REACHES_HOST_AT, _Vms.PRIVATE_NETWORK) == (False, None, False) + assert (_Vms.ON_THIS_MACHINE, _Vms.REACHES_HOST_AT, _Vms.PRIVATE_NETWORK, _Vms.WEBSOCKETS) == (False, None, False, + False) assert _Vms.SUPPORTED_SANDBOX_MODES == {SANDBOX_MODE_CONTAINER, SANDBOX_MODE_VM} assert (_Vms.SANDBOX_ACCEPTS, _Vms.CONTAINER_ACCEPTS) == (LOADABLE, NAME) assert _Vms().url_from_sandbox("http://localhost:4000") == "http://localhost:4000" From e515b6111e42ed172ffb8f0cf23ef7665a2b5c31 Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Thu, 5 Nov 2026 10:08:41 -0800 Subject: [PATCH 2/2] fix(computer): a deploy's network is checked against the links that also run it natively and carry its WebSockets The WebSocket and network checks could each pass on a different link, so a task every link would fail to deploy passed preflight. The network policy is now checked against the native links that carry WebSockets when a website browser is among the env's apps, the conditions a deploy needs of the one link it boots on. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/env/envs/computer/env.py | 12 +++++++----- tst/unit/env/envs/test_computer_env.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/agent_env/env/envs/computer/env.py b/src/agent_env/env/envs/computer/env.py index 2089e12f1..7a6d32618 100644 --- a/src/agent_env/env/envs/computer/env.py +++ b/src/agent_env/env/envs/computer/env.py @@ -259,8 +259,9 @@ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optional[dict] = None, **options) -> str | None: """Why deploy() would refuse before booting anything, or None: an option the computer provider doesn't take, a - problem with ``env_params``, or a sandbox provider that runs none of the env's arches natively, carries no - WebSockets to a website browser among its apps where it does, or can't enforce its network.""" + problem with ``env_params``, or a sandbox provider with no one link that runs one of the env's arches natively, + carries WebSockets when a website browser is among its apps, and can enforce its network, as a deploy needs of + the link it boots on.""" from agent_env.providers import build_sandbox_provider, get_env_sandbox_provider from agent_env.providers.sandbox_providers.sandbox import NetworkPolicy @@ -272,7 +273,8 @@ def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optiona forms = [params.form] if params.form else gate.forms(self.computer) native = [link for link in links if any(boot.native_links(link, arch, form, self.stored_id) for form in forms for arch in arches)] - if native and self.serves_website_browser() and not any(link.WEBSOCKETS for link in native): + usable = [link for link in native if link.WEBSOCKETS] if self.serves_website_browser() else native + if native and not usable: problems.append(f"{', '.join(type(link).__name__ for link in native)} carries no WebSockets, which its " "website browser's CDP address needs") if not native: @@ -282,8 +284,8 @@ def deploy_refusal(self, *, sandbox_type: str | None = None, env_params: Optiona f", and {', '.join(unpulled)} can't pull its containers from the image store's registry " "on this machine" if "container" in forms and unpulled else "")) policy = NetworkPolicy.from_dict(self.network) if self.network else None - if policy is not None and not any(link.supports_network_policy(policy) for link in links): - problems.append(f"{', '.join(type(link).__name__ for link in links)} can't enforce its network") + if policy is not None and usable and not any(link.supports_network_policy(policy) for link in usable): + problems.append(f"{', '.join(type(link).__name__ for link in usable)} can't enforce its network") return f"computer env {self.id!r}: " + "; ".join(problems) if problems else None @classmethod diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index ac174d94d..12457caa2 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -911,6 +911,26 @@ def test_a_deploy_its_sandbox_provider_cant_carry_out_is_refused_at_save(base, d assert problem in refusal +def test_a_chain_with_no_one_link_that_both_carries_websockets_and_enforces_the_network_is_refused_at_save( + base, driver, docker, monkeypatch, tmp_path): + class Enforces(local_sandbox.LocalSandboxProvider): + WEBSOCKETS = False + + @classmethod + def supports_network_policy(cls, policy): + return True + + class Carries(local_sandbox.LocalSandboxProvider): + pass + + monkeypatch.setattr(providers, "build_sandbox_provider", + lambda spec: ChainedSandboxProvider([Enforces(), Carries()])) + env = _put(base, driver, network={"mode": "allowlist", "allow_hosts": ["example.com"]}, + apps=[_app(None, None, tmp_path / "chromium", compose=False, name="chromium", browser=True)]) + + assert Env.get(env.id).deploy_refusal(sandbox_type="chain") == f"computer env {env.id!r}: Carries can't enforce its network" + + @pytest.mark.asyncio async def test_a_deploy_with_a_website_browser_skips_a_link_that_carries_no_websockets_and_boots_on_the_next( base, driver, docker, boots, monkeypatch, tmp_path):