Repository navigation
feat(sandbox): add a Sail Sailbox VM sandbox provider - #83
Open
psinghal20 wants to merge 5 commits into
Open
psinghal20 wants to merge 5 commits into
psinghal20 wants to merge 5 commits into
Conversation
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>
- 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>
- 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>
- 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() |
There was a problem hiding this 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.
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.
Comment on lines
+84
to
+85
| injection = cls(host=parsed.hostname, secret=secret_name(key), key=key) | ||
| _injected_keys[injection.secret] = key |
There was a problem hiding this comment.
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.This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
sail, a built-in VM sandbox provider for Sail Research Sailboxes (type = "sail",mode = "vm"), alongsidemodal_vmande2b. Each Sailbox boots from Sail'sdevboximage, which ships Docker and Compose v2 and runs as root. That means the existing docker-in-VM flows work unchanged throughVmSandbox: gateway docker-compose, agentdocker run,deploy_sandbox, image builds and artifact collection. The gateway routes it to_deploy_via_vmwith no provider-specific branch.Select it with
--sandbox sailor[sandbox] default = "sail".What's in it
sail/_sdk.py: imports the SDK lazily (import agent_envdoesn't load it) and installs the API key, described in the call-outs below.sail/sandbox.py(SailSandbox):collect_artifactsstreams. Output is queued with bounded backpressure, and output nobody reads is drained bywait(). A leadingsudois dropped, a lost host or transport gives exit-1soexec_scriptretries, and other stream errors are raised rather than reported as success.docker infoand startsdockerdonce if it isn't running. Compose v2 is required.sail/provider.py(SailSandboxProvider):timeoutbecomes the box'smax_lifetime_seconds.*.sail.boxlisteners. Create waits until every exposed port is routed.{}; an allowlist →{"allowlist": hosts + IPv4 CIDRs}. IPv6 entries and more than 128 entries are refused before provisioning.EGRESS_HOSTS = ("*.sail.box",).get_sandboxrestores the tunnel URLs from listeners and the applied egress policy. An unrepresentable policy becomesNone(fail closed).failedstatus, 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._BUILTIN_SANDBOX_PROVIDERS, the package exports, the CLI--sandboxhelp, the AGENTS.md provider list,sail~=0.12.8inpyproject.toml(the lock addssailandcloudpickle), andTHIRD_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.pyand the Sail provider; no core code changes.create_sandboxreadsLITELLM_API_KEYandLITELLM_BASE_URLfrom the agent's env. It stores the key as a Sail secret namedAGENTENV_LITELLM_<sha256(key)[:32]>, so one key maps to one secret and different keys never share one.authorization: Bearer ${secrets.…}andx-api-key: ${secrets.…}on requests to the endpoint's host. A restricted allowlist also gets that host.SailSandboxreplaces the key's value withsail-injected-model-key, sodocker run -eanddocker exec -e(agent, judge, unit-test verifier) carry only the placeholder.dockershim at/usr/local/bin/dockergives every container it runs or creates the VM's CA bundle, plusSSL_CERT_FILE,REQUESTS_CA_BUNDLE,NODE_EXTRA_CA_CERTSandCURL_CA_BUNDLE.SecretInUseError) and we leave it in place.[model]key, if that is the injected one. Either way, the reconnected handle keeps scrubbing the key.httpone is refused before anything is created, unlessinject_model_key = false.docker run/docker createon the VM. Containers started by an agent's own Docker-in-Docker don't get the CA.deploy_sandboxand othercreate_vmboxes 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 readsSAIL_API_KEYonce, when it builds its process-wide client._sdk.connect:App.findcall that builds the client;A different key in the same process raises
ConfigError(one Sail key per process). An operator's ownSAIL_API_KEYis overridden only for that build, then restored. The key is never passed to workloads, exec env,CONTAINER_ENV,REQUEST_HEADERS, logs orrepr. 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_KEYandSAIL_RUNTIME_THREADSare 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:share_credentials = true: frozen host AWS credentials, so keep this off on Sail or use a scoped, short-lived role;extra_env_vars;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 structuredagent_env.sail_sandbox_startedlog event keyed bysailbox_id, the same pattern as Modal'smodal_sandbox_started. Per-box cost comes from Sail's spend API joined onsailbox_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_MODEis not supported. Core must not read a stage variable, so the provider uses Sail's production endpoint.Lazy SDK import.
_sdk.connectimportssailinside the function, the same as E2B's provider does, so the Rust core loads only when Sail is used.Testing
make unit-testgives 6338 passed, 13 skipped, with the SDK faked and nothing offline touching the network. New tests aresail_sandbox_provider_test.py,sail_sandbox_test.pyandsail_model_key_test.py(secret naming, the rules, the shim's argument rewriting, create, scrubbing, policy replacement, cleanup order and reconnect).sailis also added to the network-policy stamping test, the egress-host floor, config interpolation, the capability gate and the gateway VM-path routing test.int_test_slow):sail_sandbox_smoke_test.py: Docker in the box, nginx served through a*.sail.boxURL, 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 anddocker inspectshow only the placeholder, and the secret is gone after terminate.sailis added to thesandbox_providerparametrisations ingateway_test.pyandtask_steps_test.py.agentenv-capability-missing: remote_sandbox.sail==0.12.8, a local key, on the final code):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;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
The PR does not appear safe to merge until concurrent launches cannot delete each other’s model-key secret.
Fix with agent prompt
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.
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]Reviews (5) · Last reviewed commit: "fix(sail): address greptile review findi..." · Reviewed by Greptile