Skip to content

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

Open
psinghal20 wants to merge 5 commits into
mainfrom
feat/sail-sandbox-provider
Open

psinghal20 wants to merge 5 commits into
mainfrom
feat/sail-sandbox-provider

Conversation

@psinghal20

@psinghal20 psinghal20 commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds sail, a built-in VM sandbox provider for Sail Research Sailboxes (type = "sail", 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.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

Select it with --sandbox sail or [sandbox] default = "sail".

What's in it

  • sail/_sdk.py: imports the SDK lazily (import agent_env doesn't load it) and installs the API key, described in the call-outs below.
  • sail/sandbox.py (SailSandbox):
    • 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/provider.py (SailSandboxProvider):
    • 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: _BUILTIN_SANDBOX_PROVIDERS, the package exports, the CLI --sandbox help, the AGENTS.md provider list, sail~=0.12.8 in pyproject.toml (the lock adds sail and cloudpickle), and THIRD_PARTY_NOTICES.md.

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/model_key.py and the Sail 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.
  • 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 SailSandbox replaces the key's value with sail-injected-model-key, so docker run -e and docker exec -e (agent, judge, unit-test verifier) carry only the placeholder.
  • 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.
    • Terminate deletes the policy, then the secret. If another Sailbox's policy still names the secret, Sail refuses (SecretInUseError) and we leave it in place.
    • A failed or cancelled create deletes the policy and the secret, and a failed policy swap deletes the replacement.
    • Reconnect restores the injection from the applied policy. A handle reconnected in the process that deployed the agent recovers the agent's key from process memory (never persisted). In another process, it recovers the configured [model] key, if that is the injected one. Either way, the reconnected handle keeps scrubbing the key.
  • Limits.
    • The endpoint must be HTTPS; an http one is refused before anything is created, unless inject_model_key = false.
    • Only the agent's own key is scrubbed. A different key sent later into the same box (for example a verifier configured with another key) is not.
    • 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_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.

  • Lazy SDK import. _sdk.connect imports sail inside the function, the same as E2B's provider does, so the Rust core loads only when Sail is used.

Testing

  • Unit: make unit-test gives 6338 passed, 13 skipped, with the SDK faked and nothing offline touching the network. New tests are sail_sandbox_provider_test.py, sail_sandbox_test.py and sail_model_key_test.py (secret naming, the rules, the shim's argument rewriting, create, scrubbing, policy replacement, cleanup order and reconnect). sail 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_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 secret is gone after terminate.
    • sail 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: passed;
    • gateway_test.py::test_gateway_with_multi_mcp_server[sail]: 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] (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: 4/5

The PR does not appear safe to merge until concurrent launches cannot delete each other’s model-key secret.

Fix All in CursorFindings

  1. P1 Failed launch deletes shared key ▶
  2. P2 Old model keys remain ▶
Fix with agent prompt
### Issue 1
src/agent_env/providers/sandbox_providers/sail/provider.py:267-270
When two agent launches use the same `LITELLM_API_KEY`, they share a Sail secret. If one launch fails while the other is still creating its policy, this cleanup can delete the shared secret. The second agent may fail to start or lose access to its model. Keep the secret until no launch is creating or using a policy that needs it.

### Issue 2
src/agent_env/providers/sandbox_providers/sail/model_key.py:84-85
`for_env` adds each injected key to `_injected_keys`, but ending a box or failing to create one does not remove it. A long-running worker keeps old agent keys in memory and adds another entry for each distinct key. Remove entries when they are no longer needed for reconnects.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds Sail Sailboxes as a built-in VM sandbox provider for agents, gateways, and other Docker-based work. It also lets Sail inject an agent’s model key at the HTTPS endpoint, keeping the real key out of commands and files sent to the box.

  • Sail now runs Docker-based work in its own VM sandboxes.
  • Sail adds an agent’s model key to HTTPS requests without storing it in the VM.
  • Gateway trajectory reads retry when a transport failure cuts off an exec.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Create agent box] --> B[Set shared model-key secret]
  B --> C[Create saved egress policy]
  C --> D[Create Sailbox]
  C --> E[Failed launch cleanup]
  E --> F[Delete policy, then shared secret]
Loading

Reviews (5) · Last reviewed commit: "fix(sail): address greptile review findi..." · 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/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/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/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
- 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 on lines +267 to +270
except BaseException as exc:
# A create cancelled in flight is _create_or_reclaim's to clean up once it settles.
if release is not None and not (creating and isinstance(exc, asyncio.CancelledError)):
await release()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed launch deletes shared key

When two agent launches use the same LITELLM_API_KEY, they share a Sail secret. If one launch fails while the other is still creating its policy, this cleanup can delete the shared secret. The second agent may fail to start or lose access to its model. Keep the secret until no launch is creating or using a policy that needs it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/providers/sandbox_providers/sail/provider.py
Line: 267-270

Comment:
**Failed launch deletes shared key**

When two agent launches use the same `LITELLM_API_KEY`, they share a Sail secret. If one launch fails while the other is still creating its policy, this cleanup can delete the shared secret. The second agent may fail to start or lose access to its model. Keep the secret until no launch is creating or using a policy that needs it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +84 to +85
injection = cls(host=parsed.hostname, secret=secret_name(key), key=key)
_injected_keys[injection.secret] = key

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Old model keys remain

for_env adds each injected key to _injected_keys, but ending a box or failing to create one does not remove it. A long-running worker keeps old agent keys in memory and adds another entry for each distinct key. Remove entries when they are no longer needed for reconnects.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/providers/sandbox_providers/sail/model_key.py
Line: 84-85

Comment:
**Old model keys remain**

`for_env` adds each injected key to `_injected_keys`, but ending a box or failing to create one does not remove it. A long-running worker keeps old agent keys in memory and adds another entry for each distinct key. Remove entries when they are no longer needed for reconnects.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

This branch has not been deployed

No deployments
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.

1 participant