Skip to content

feat(sandbox): add a Sail Sailbox VM sandbox provider - #83

Merged
psinghal20 merged 12 commits into
mainfrom
feat/sail-sandbox-provider
Oct 7, 2026
Merged

psinghal20 merged 12 commits into
mainfrom
feat/sail-sandbox-provider

Conversation

@psinghal20

@psinghal20 psinghal20 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds sail_vm, a built-in VM sandbox provider for Sail Research Sailboxes (type = "sail_vm", mode = "vm"), alongside modal_vm and e2b. Each Sailbox boots from Sail's devbox image, which ships Docker and Compose v2 and runs as root. That means the existing docker-in-VM flows work unchanged through VmSandbox: gateway docker-compose, agent docker run, deploy_sandbox, image builds and artifact collection. The gateway routes it to _deploy_via_vm with no provider-specific branch.

[sandbox.providers.sail_vm.config]
api_key = "secret:sail_api_key"   # required; env:SAIL_API_KEY for local dev
app = "agent-env"                 # optional: the Sail App every Sailbox belongs to
min_size = "s"                    # optional: smallest size to pick (s|m|l)
auto_sleep = false                # optional: default false (never sleep)
# auto_sleep_min_idle_seconds = 600   # optional, 1-3600; turns autosleep on
# runtime_threads = 16                # optional, sets SAIL_RUNTIME_THREADS (1-256)
inject_model_key = true           # optional: default true, Sail injects the agent's model key

Install the optional extra with pip install 'agentenv-framework[sail]', then select it with --sandbox sail_vm or [sandbox] default = "sail_vm". Usage is documented in sandbox_providers/sail_vm/README.md.

What's in it

  • sail_vm/_sdk.py: imports the Sail SDK (the sail extra) and installs the API key, described in the call-outs below. The package's __init__ imports nothing and the registry points at sail_vm.provider, so core never loads the SDK.
  • sail_vm/sandbox.py (SailVmSandbox):
    • Exec: separate stdout and stderr, exact bytes, async-iterable so collect_artifacts streams. Output is queued with bounded backpressure, and output nobody reads is drained by wait(). A leading sudo is dropped, a lost host or transport gives exit -1 so exec_script retries, and other stream errors are raised rather than reported as success.
    • Docker readiness: polls docker info and starts dockerd once if it isn't running. Compose v2 is required.
    • Host file writes: go through Sail's native filesystem API instead of base64 chunks.
    • Downloads under an allowlist: before an image or object download, the signed-URL hosts are added to the box's egress policy. This happens under a per-Sailbox lock with a fresh read of the applied policy, and is skipped when the cached policy already admits the hosts. It fails closed on an unknown policy and refuses to exceed Sail's 128 entries.
  • sail_vm/provider.py (SailVmSandboxProvider):
    • Sizing: picks the smallest size covering the CPU request (s/m/l = 1/4/8 vCPU). Memory and disk become ceilings, rounded up into that size's range. A request no size fits is refused before provisioning.
    • Lifetime: timeout becomes the box's max_lifetime_seconds.
    • Ports: public *.sail.box listeners. Create waits until every exposed port is routed.
    • Egress policies: allow-all → {}; an allowlist → {"allowlist": hosts + IPv4 CIDRs}. IPv6 entries and more than 128 entries are refused before provisioning. EGRESS_HOSTS = ("*.sail.box",).
    • Reconnect: get_sandbox restores the tunnel URLs from listeners and the applied egress policy. An unrepresentable policy becomes None (fail closed).
    • Create failures: a failed status, or a failure during setup or port routing, terminates the box.
    • create_container: reuses the inherited login → pull → run, then deletes /root/.docker/config.json.
  • Registration: one line in _BUILTIN_SANDBOX_PROVIDERS, a sail extra in pyproject.toml (sail~=0.12.8, also pulled in by dev; the lock adds sail and cloudpickle), THIRD_PARTY_NOTICES.md, and the AGENTS.md provider list.

The agent's model key never enters the Sailbox

Sail's credential injection adds the key to the agent's model requests as they leave the Sailbox. The Sailbox disk (which Sail checkpoints, see below) and its memory hold only a placeholder. This lives entirely in sail_vm/model_key.py and the sail_vm provider; no core code changes.

  • Secret named by the key. create_sandbox reads LITELLM_API_KEY and LITELLM_BASE_URL from the agent's env. It stores the key as a Sail secret named AGENTENV_LITELLM_<sha256(key)[:32]>, so one key maps to one secret and different keys never share one. The secret is never deleted, so no launch can remove one that a concurrent launch (in this process or another) still needs. It holds only a key Sail already stores; sweep AGENTENV_LITELLM_* if you rotate keys.
  • Saved policy applied at create. A saved egress policy is attached when the Sailbox is created, so it's in force before the agent starts. Its rules set authorization: Bearer ${secrets.…} and x-api-key: ${secrets.…} on requests to the endpoint's host. A restricted allowlist also gets that host.
  • Placeholder only inside. Every command and file write from that SailVmSandbox replaces the key with sail-injected-model-key, in every form agent-env writes it (raw, shell-quoted, escaped inside single quotes). So docker run -e and docker exec -e (agent, judge, unit-test verifier) carry only the placeholder.
  • Refused rather than sent. With injection on, any Sailbox refuses a command or file write that assigns LITELLM_API_KEY/ANTHROPIC_API_KEY a value other than the placeholder. In shell scripts each word is decoded as the shell reads it, quoted or escaped names included, and only a value that is wholly one $NAME reference passes. Files are checked literally, KEY: value (YAML/JSON) included. Keys under 16 characters are refused, since scrubbing replaces every occurrence. That covers an agent deployed into an existing Sail sandbox, and a reconnected handle that can't recover the key. Shell scripts are read as the shell reads them ($VAR references pass); other arguments and file contents are taken literally.
  • Containers trust Sail's CA. Sail terminates TLS for rule hosts with its own CA, which is in the VM's trust store but not in containers'. A docker shim at /usr/local/bin/docker gives every container it runs or creates the VM's CA bundle, plus SSL_CERT_FILE, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS and CURL_CA_BUNDLE.
  • Lifecycle.
    • Widening the allowlist for downloads replaces the saved policy and keeps the rules; inline documents can't name secrets.
    • Every saved policy made for a Sailbox shares a name prefix, which a reconnected handle reads from the applied policy. Terminate, from any handle in any process, deletes the whole family. Policy changes and terminate are serialized per Sailbox in a process.
    • A failed or cancelled create deletes its policy. A policy is named before it's created, so one whose create response was lost is found by name and deleted, with retries. A failed policy swap deletes its replacement. One whose outcome Sail can't report keeps both ids until it can, and one that applied is settled even when cancelled. A create whose response was lost terminates the Sailbox it made (found by its name, with retries) before releasing the policy.
    • Keys are held per launch and per Sailbox in process memory (never persisted) and forgotten once nothing needs them. A handle reconnected in the deploying process recovers the agent's key from there; in another process it recovers the configured [model] key, if that is the one injected. A key it can't recover is refused, not sent.
  • Limits.
    • The endpoint must be HTTPS; an http one is refused before anything is created, unless inject_model_key = false.
    • A different model key sent into a Sail box (for example an agent deployed into an existing sandbox) is refused; use the agent's own Sail sandbox, or inject_model_key = false.
    • The shim covers docker run / docker create on the VM. Containers started by an agent's own Docker-in-Docker don't get the CA.
    • Gateway, deploy_sandbox and other create_vm boxes carry no model key and don't inject.

Call-outs for reviewers

  • The Sail API key is written to os.environ. This is the first core provider to do that. The Sail Python SDK has no client that takes a key explicitly; it reads SAIL_API_KEY once, when it builds its process-wide client. _sdk.connect:

    • sets the configured key under a lock;
    • makes the one synchronous App.find call that builds the client;
    • restores the previous value straight away, about 0.4s later.

    A different key in the same process raises ConfigError (one Sail key per process). An operator's own SAIL_API_KEY is overridden only for that build, then restored. The key is never passed to workloads, exec env, CONTAINER_ENV, REQUEST_HEADERS, logs or repr. A local-sandbox subprocess started during that window could inherit it, but the local provider already runs as the same user with the full host environment, so this adds no exposure. SAIL_API_KEY and SAIL_RUNTIME_THREADS are added to the env-var guard's not-configuration set: agent-env only writes them for the SDK and never reads them as configuration.

  • Sail checkpoints every Sailbox automatically. A box with autosleep off was checkpointed about 2.5 minutes after creation with nothing on our side triggering it, and there is no setting to turn this off. The model key is kept out (above), and registry credentials are deleted after create_container's pull. Other values deployments put in container env still land on disk and in checkpoints:

    • object-store credentials when the S3 store has share_credentials = true: frozen host AWS credentials, so keep this off on Sail or use a scoped, short-lived role;
    • MCP server extra_env_vars;
    • an external env-state database URL.

    Retention, whether checkpoints include memory, and deletion on terminate are open questions for Sail.

  • A cancelled create is cleaned up. If the caller is cancelled while a create is in flight (for example by ChainedSandboxProvider's 180s deadline), the box it produces is terminated: three attempts with backoff, then an error log naming the box. Otherwise it would leak a billed Sailbox.

  • Attribution is not on the Sailbox. Sail has no labels or tags. Attribution goes in the box name (ae-<random>-<slugged values in key order>, ≤128 chars) and in a structured agent_env.sail_vm_sandbox_started log event keyed by sailbox_id, the same pattern as Modal's modal_sandbox_started. Per-box cost comes from Sail's spend API joined on sailbox_id.

  • Autosleep is off by default (AutoSleep.never()), so idle MCP servers and gateways don't sleep or come back cold. Config can turn it on.

  • Resource requests round up. Requests are ceilings on Sail (billing is for observed usage), so CPU rounds up to a size and memory and disk round up into the size's range. This raises a cap, not the bill.

  • SAIL_MODE is not supported. Core must not read a stage variable, so the provider uses Sail's production endpoint.

  • Optional and self-contained. sail is an optional extra like gcp. Without it, selecting sail_vm fails with load_impl's standard ConfigError naming the extra, and the contribution helpers (egress hosts, container env) skip the provider. The source change is confined to sandbox_providers/. The gateway exec-transport hardening (retrying read_trajectory / container lookups that lose their exec connection) was split out for a separate PR.

Testing

  • Unit: make unit-test gives 6602 passed, 13 skipped, with the SDK faked and nothing offline touching the network. New tests are sail_vm_sandbox_provider_test.py, sail_vm_sandbox_test.py and sail_vm_model_key_test.py (secret naming, the rules, the shim's argument rewriting, create, scrubbing, policy replacement, cleanup order and reconnect). sail_vm is also added to the network-policy stamping test, the egress-host floor, config interpolation, the capability gate and the gateway VM-path routing test.
  • Integration (int_test_slow):
    • New sail_vm_sandbox_smoke_test.py: Docker in the box, nginx served through a *.sail.box URL, an exact binary write round-trip, reconnect restoring ports and policy, and an allowlist enforced for containers. A third test checks model-key injection end to end: a container's request reaches a header-echo endpoint with the real key, while the container's env and docker inspect show only the placeholder, and the policy is gone after terminate.
    • sail_vm is added to the sandbox_provider parametrisations in gateway_test.py and task_steps_test.py.
    • With no Sail config, these skip with agentenv-capability-missing: remote_sandbox.
  • Run live against Sail (sail==0.12.8, a local key, on the final code):
    • smoke tests: 3 passed;
    • agent-env run hello --sandbox sail_vm: passed;
    • gateway_test.py::test_gateway_with_multi_mcp_server[sail_vm]: passed in about 1m40s on the Sailbox, covering two MCP servers, a universe load, MCP tool calls and /step;
    • no Sailboxes were left running afterwards.
  • Not run live: task_steps_test[sail_vm] (needs a model endpoint), autosleep staying off through a long idle period, and how promptly a box expires at its max lifetime.

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

The changes since the previous review appear safe to merge.

What we checked:

  • Setup command fails: The CLI accepts --sandbox, and the package registers the bundled hello task.

Summary

Adds sail_vm, a built-in VM sandbox provider for Sail Research Sailboxes, so agent-env workloads can run on Sail’s Docker-ready VMs. It also keeps agent model keys out of those VMs by having Sail add them to outgoing requests.

  • Sailboxes can now run agent-env workloads as Docker-ready VMs.
  • Sail adds an agent’s model key to requests outside the Sailbox.

Reviews (13) · Last reviewed commit: "docs(sail_vm): walk through setup with c..." · Reviewed by Greptile

Adds `sail`, a built-in VM sandbox provider for Sail Research Sailboxes.
A Sailbox boots from Sail's devbox image (Docker and Compose v2, root),
so the gateway, agent, deploy_sandbox and image-build paths run on it
unchanged through VmSandbox.

- Config: [sandbox.providers.sail.config] api_key (secret:/env: ref,
  required), app, min_size, auto_sleep (off by default),
  auto_sleep_min_idle_seconds, runtime_threads.
- Resources map to the smallest Sail size covering the CPU; memory and
  disk are ceilings rounded up into the size's range.
- Ports are public *.sail.box listeners; reconnect restores them and
  the applied egress policy (fail closed when unrepresentable).
- Egress allowlists of hostnames and IPv4 CIDRs, up to Sail's 128
  entries; signed download hosts are added under a per-Sailbox lock.
- Exec streams output with bounded backpressure; a lost host maps to
  exit -1 so exec_script retries.
- A create cancelled by its caller terminates the Sailbox it yields.
- Attribution goes in the Sailbox name and an
  agent_env.sail_sandbox_started log event keyed by sailbox_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@psinghal20
psinghal20 requested a review from a team as a code owner October 6, 2026 23:18
Comment thread src/agent_env/providers/sandbox_providers/sail_vm/provider.py
Comment thread src/agent_env/providers/sandbox_providers/sail/sandbox.py Outdated
Comment thread src/agent_env/providers/sandbox_providers/sail/_sdk.py Outdated
- create_container terminates the VM when cancelled during login, pull
  or run (the cleanup caught Exception, not CancelledError), so a
  cancelled deploy no longer leaves a billed VM behind.
- A Sail output stream cut by a lost host or transport ends instead of
  raising from read(), so wait() reports exit -1 and exec_script retries.
- SAIL_RUNTIME_THREADS is restored after the SDK builds its client, like
  SAIL_API_KEY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py
psinghal20 and others added 2 commits October 6, 2026 16:56
- read_trajectory retries a read that exited -1 (lost exec transport,
  possibly partial output) and raises after three attempts instead of
  handing a partial tool-call history to the agent checks. Other exit
  codes keep their behaviour, so a gateway with no history is still [].
- SailSandbox.exec_with_output returns no stdout for a command whose
  output stream was cut, so a caller that skips the exit code can't
  mistake partial output for the whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sail injects the key into the agent's model requests as they leave the
Sailbox, so neither the VM disk (which Sail checkpoints) nor its memory
ever holds it. On by default; [sandbox.providers.sail.config]
inject_model_key = false passes the key in as before.

- create_sandbox stores the env's LITELLM_API_KEY as a Sail secret named
  from its SHA-256 (one key, one secret; keys never share one) and
  creates the Sailbox with a saved egress policy whose rules set it as
  the authorization and x-api-key headers for LITELLM_BASE_URL's host,
  which a restricted allowlist also admits. An http endpoint is refused.
- Every command and file the SailSandbox sends is scrubbed of the key,
  so docker run/exec -e carry a placeholder.
- A docker shim on the Sailbox gives every container it runs or creates
  the VM's CA bundle, which holds the CA Sail terminates those requests'
  TLS with (SSL_CERT_FILE, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS,
  CURL_CA_BUNDLE).
- Widening the allowlist replaces the saved policy, keeping its rules.
- Terminate deletes the policy, then the secret unless another Sailbox's
  policy still names it; a failed or cancelled create cleans up too.
- Reconnect restores the injection from the applied policy, and scrubs
  the configured [model] key when it is the injected one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/providers/sandbox_providers/sail_vm/provider.py
Comment thread src/agent_env/providers/sandbox_providers/sail/provider.py Outdated
Comment thread src/agent_env/providers/sandbox_providers/sail/sandbox.py Outdated
- A handle reconnected in the process that injected a key recovers that
  key (kept in process memory, never persisted), so it keeps scrubbing an
  agent's own key even when it isn't the configured [model] key.
- A create that fails, or is cancelled before the Sailbox create starts,
  deletes the model-key secret along with its policy; one abandoned in
  flight releases both once the create settles, if it fails.
- A policy swap that fails or is cancelled deletes the replacement
  policy and keeps tracking the one still applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/providers/sandbox_providers/sail_vm/provider.py
Comment thread src/agent_env/providers/sandbox_providers/sail/model_key.py Outdated
- Refuse rather than send: a Sailbox (with inject_model_key on) refuses
  any command or file write assigning LITELLM_API_KEY/ANTHROPIC_API_KEY a
  value other than the placeholder, so a key Sail doesn't inject (an
  agent deployed into an existing sandbox, or one a reconnected handle
  can't recover) never reaches its disk. Shell scripts are read as the
  shell reads them; other arguments and file bytes are literal.
- Scrub the key in every form agent-env writes it: shlex-quoted, escaped
  inside single quotes, and raw.
- Never delete the key's secret: it is named from the key, so a launch
  can no longer remove one a concurrent launch (here or in another
  process) still needs. The Sailbox's saved policy is still deleted.
- Keys are held per launch and Sailbox in this process and forgotten
  once none needs them.
- Policy changes and terminate are serialized per Sailbox; terminate
  deletes the policy actually applied.
- Saved policies are named before creation, so one whose create response
  is lost is found by name and deleted, with retries.
- The gateway's container lookup retries a lost exec transport and
  raises rather than read it as no container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py
@psinghal20

psinghal20 commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

@greptile-apps re-review

Comment thread src/agent_env/providers/env_providers/env_gateway_provider.py Outdated
psinghal20 and others added 3 commits October 7, 2026 09:21
The gateway and sidecar log dumps after compose up go through
_log_service, which reports a container lookup that keeps losing its
exec transport instead of raising it into the deploy. read_trajectory
still raises, since there a failed lookup must not read as no history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m API

main now pushes such objects onto a VM as base64 chunks over exec,
reading them through ObjectStore.open. A Sailbox takes them through its
own filesystem API instead, streamed a chunk at a time with no base64 or
per-chunk exec, which also keeps them out of the model-key guards meant
for agent-env's own commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/providers/sandbox_providers/sail_vm/sandbox.py
psinghal20 and others added 2 commits October 7, 2026 10:40
The provider, its sandbox type and its config table are now sail_vm
([sandbox.providers.sail_vm.config], --sandbox sail_vm), the package
sandbox_providers/sail_vm, and the classes SailVmSandbox and
SailVmSandboxProvider, matching modal_vm for a VM-mode provider.

THIRD_PARTY_NOTICES: sail ships no license file and its source isn't
public, so its entry says so and points at PyPI, as for other packages
without one, rather than linking a generic Apache text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onal extra

Review follow-ups for the sail_vm provider.

- sail is an optional extra (pip install 'agentenv-framework[sail]'); dev
  includes it. The package's __init__ imports nothing and the registry
  points at sail_vm.provider, so core never loads the SDK and selecting
  sail_vm without the extra names the extra to install.
- The source change stays in sandbox_providers/: the CLI help and the
  providers/__init__ exports are reverted, and the gateway exec-transport
  fixes leave this PR for their own.
- sail_vm/README.md documents install, config, sizing, networking,
  model-key injection, attribution and caveats.
- Guard: shell words are decoded as the shell reads them (a quoted or
  escaped name is still a key), a value passes only when it is wholly
  one variable reference, and YAML/JSON KEY: value is caught in files.
  Keys under 16 characters are refused, since scrubbing replaces every
  occurrence.
- Policies: every saved policy for a Sailbox shares a name prefix, so
  teardown from any handle in any process deletes them all; a swap whose
  outcome Sail can't report keeps both ids, one that applied is settled
  even when cancelled, and a create whose response is lost terminates the
  Sailbox it made (by name, with retries) before releasing its policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@psinghal20

Copy link
Copy Markdown
Collaborator Author

@greptile-apps re-review the pr

@psinghal20

Copy link
Copy Markdown
Collaborator Author

Follow-up (separate PR): gateway reads that lose their exec connection

Greptile's remaining concern is a core gateway issue, which this PR leaves alone to keep its source changes inside sandbox_providers/.

EnvironmentGatewayProvider ignores the exit code of its in-VM reads. When a provider's exec connection drops (exit -1), _get_container_id reads the empty result as "no container", and read_trajectory parses whatever came back. An agent check can then record an empty or partial tool-call history as real. This affects every provider; sail_vm makes it more visible because its exec_with_output returns no stdout for a cut stream.

The fix will come in its own PR from main:

  • read_trajectory and _get_container_id retry a read that exited -1 and raise after three attempts, instead of reading it as no container or no history. A gateway with no history still returns [].
  • The deploy-time log dumps become best-effort, so a failed lookup or docker logs call never aborts a healthy deploy.
  • Unit tests for each. It was verified with the live gateway multi-MCP test on Sail before being split out of this PR.

Set up in four steps (install the extra, get a key, a complete
config.toml, a first run with its output), a reference table of every
config key, and examples for a shared deployment, per-run model keys, a
sandbox with restricted egress, auto-sleep, a non-HTTPS model endpoint,
a fallback chain and the Python API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@psinghal20
psinghal20 merged commit 6e20760 into main Oct 7, 2026
13 checks passed
@psinghal20
psinghal20 deleted the feat/sail-sandbox-provider branch October 7, 2026 21:17
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.

3 participants