fix(sandbox): free volumes and work dirs when a sandbox is destroyed - #523
Merged
Conversation
Destroying a standalone sandbox removed its container but left its home volume and its entire host work dir on disk, so a host that creates and destroys sandboxes filled up with storage no API could reach. The home volume leaked because create and destroy disagreed on its name. `create` built `temps-sandbox-home-<config.run_id>` while `destroy` rebuilt the name by stripping the prefix off the container name. Those match for agent runs (`temps-sandbox-<run_id>`), which is why this went unnoticed, but standalone sandboxes override the container suffix with their opaque `public_id` label — so destroy asked Docker to remove a volume that had never existed, logged "may not exist", and left the real one behind on every single delete. Both sides now derive the name from the container name via one `home_volume_name` function, which also fixes a latent collision where sandbox row 5 and agent run 5 shared `temps-sandbox-home-5`. The work dir simply had no cleanup: `create_sandbox` makes `data_root/<public_id>` and nothing ever removed it, stranding node_modules, build output and cloned repos per destroyed sandbox. Removal is guarded by a `sbx_<16 hex>` shape check, because `data_root.join()` resolves `..` and absolute paths and this is a recursive delete. Naming fixes only help sandboxes destroyed from now on, so the provider also gains `reap_orphaned_volumes`, swept hourly (and once at startup, so an upgrading host gets its disk back immediately). It removes only `temps-sandbox-home-*` volumes that Docker's own `dangling=1` filter reports as unreferenced and never passes `force`, so a stopped sandbox — a normal resumable state the expiration sweeper parks idle sandboxes in — keeps its home dir. Verified on a live daemon: the new e2e fails on the old naming with "no such volume", passes after, and the reap pass reclaimed five real orphans that had accumulated on the dev host.
📓 Changelog previewThis is what your commits will add to the generated ## [Unreleased]
### Documentation
- **sandbox:** Record the ordering the work-dir reap depends on
### Fixed
- **sandbox:** Free volumes and work dirs when a sandbox is destroyed
- **sandbox:** Only reap volumes no live sandbox claims
- **sandbox:** Keep generated volume names out of the pre-fix namespace
### Revert
- **sandbox:** Drop the background orphan-volume reaper
### Testing
- **sandbox:** Adapt the storage-cleanup harness to the cookie_crypto dependency |
Review found the orphan reaper could delete a home volume out from under
a live sandbox. `create` force-removes a same-named leftover container
before it resolves the image, so a sandbox being recreated has an
unreferenced home volume for the length of an image pull — minutes on a
cold pull. Docker's `dangling` filter reports it as garbage and the reap
took it, handing the recreated container an empty home. Same hole voided
`destroy(purge_volumes: false)`, documented two files away as the way to
preserve Claude auth and shell history across a session close+reopen: a
deliberately preserved volume has no container either.
"No container references this" is a much weaker claim than "no sandbox
wants this", and only the database can tell them apart. The sweeper now
passes the provider the set of volume names live sandboxes may still
need, and the provider refuses to touch anything in it. The set
deliberately over-claims — all three naming schemes plus non-terminal
agent runs — because a name costs nothing and a wrong deletion is
unrecoverable. If that query fails the reap is skipped entirely rather
than run against a partial set.
Volumes are also stamped with an `sh.temps.sandbox.home` label at create
and must now prove ownership (label, or a name shaped exactly the way
this provider generates them) before removal, so an operator's own
`temps-sandbox-home-backup` survives a prefix match. Unlabelled volumes
still qualify by shape — they are the pre-upgrade backlog the reap exists
to clear.
Also from review:
- Both create-failure paths leaked the work dir the PR claimed to fix.
The seed-source arm is the costly one: the directory can already hold a
full clone, and the row is destroyed, so no later destroy could reach
it. A work-dir reap now backstops both, which in turn lets destroy skip
its own cleanup when the provider destroy failed and the container may
still be running with that dir bind-mounted.
- Firecracker no longer inherits the no-op default. It owns multi-GB
rootfs-cache entries that were collected only as a side effect of
destroy, so a host that stopped destroying cleanly kept them forever.
- The unit tests didn't fail on the old code — they asserted the helper,
not what `create` derives. `create` now takes both names from one
`sandbox_names`, and the new test pins the pair, so Docker-less CI
finally guards the original leak.
- Volume-list failures reported as `SandboxExecFailed { run_id: 0 }`; now
`SandboxProviderUnavailable`. Daemon list warnings are logged instead
of silently treated as a complete page. Reap cadence is pinned by a
test rather than an unenforced comment. ADR-029's stale
`temps-sandbox-home-{run_id}` line corrected.
Verified: neutering the claim check makes the reap e2e fail on the
preserved volume; restored, 260 + 110 tests pass and both Docker e2es run
against a live daemon.
Three review rounds, three different ways for the sweep to delete the wrong thing. Round 2 found it deleted a live sandbox's home volume during the image pull that `create` opens by force-removing the old container first. Round 3 found the fix for that still deletes any volume belonging to a second temps instance on the same Docker daemon — which is the normal local dev setup, several worktree slots each with its own database — because a foreign instance's volumes can never appear in this instance's claim set. The work-dir sweep had the same shape: `sandboxes` rows are `ON DELETE CASCADE` from `users`, so deleting a user hard-deletes the rows while the containers keep running, and the next sweep would recursively delete live workspaces. The pattern is the design, not the details. A sweep has to *infer* which storage is garbage; an explicit destroy is *told*. Every predicate we added — dangling, labelled, name-shaped, unclaimed — narrowed the wrong answers without ever making them impossible, and the failure is silent and irreversible: a user's /home/temps holds their Claude credentials, shell history and project state. So volumes and work dirs are now freed only when a sandbox is explicitly destroyed, which is the case that was broken to begin with. Removed the `reap_orphaned_volumes` provider hook, the Docker and Firecracker implementations, the router fan-out, and the sweeper's claim-set and work-dir reaps; `expiration_sweeper.rs` and `plugin.rs` are back to their original contents. Kept, because they stand on their own: - The naming fix and its Docker-less guard. This is the actual leak. - Work-dir removal on destroy and on both create-failure paths. With no sweep to fall back on, destroy no longer skips this when the provider destroy failed — that would leak the directory permanently, and the user did ask for the sandbox to be gone. - The `sh.temps.sandbox.home` label. Nothing reads it; it exists so an operator can reclaim volumes stranded by older builds with `docker volume prune --filter label=sh.temps.sandbox.home`, which is explicit and cannot run while they aren't looking. Verified: 258 + 109 tests pass, clippy clean, and the destroy e2e still runs against a live daemon and still fails on the old naming.
Review found the naming fix wasn't retroactive in a way that matters. On an upgraded host a pre-fix standalone sandbox's volume is stranded — destroy computes the new name and misses — but it stays on disk under `temps-sandbox-home-<sandboxes.id>`. Agent runs still name theirs `temps-sandbox-home-<agent_runs.id>`, and Docker attaches an existing volume by name, so the next agent run whose id matched would silently mount a previous standalone sandbox's /home/temps: another user's Claude credentials, shell history and project state, read-write. Two independent id sequences sharing one namespace, which is the same root cause as the original collision. Every name this build generates now carries a `v2-` scheme marker, so it cannot land on a pre-fix name. Stranded legacy volumes become inert. Recreating a container across the upgrade costs one fresh home; agent-run homes are ephemeral and standalone containers aren't recreated in place (stop/start/restart reuse the container), so nothing in use is lost. Also from review: - The documented reclaim command was wrong. Legacy volumes were created implicitly by the bind and carry no label, and `create_volume` doesn't retro-apply one, so the label-filtered prune matched none of the volumes it was advertised for. Documented the prefix+dangling command for those. - The provider-create failure arm deleted the work dir without tearing the container down. `create` can fail after `start_container` (ownership normalisation propagates), and containers are `restart-unless-stopped`, so it could leave a live container holding the caller's env vars, unreachable once the row is destroyed, with its /workspace deleted under it. Now destroys first, like the seeding arm. - Moved the labelled `create_volume` to just before `create_container` so a caller looping failed creates with a bogus image can't mint a volume per attempt. - Dropped a stale comment promising the reverted reaper would clean up. Tests. The gap review found was that nothing covered the call sites, only the pure helpers — deleting the `remove_work_dir` calls left everything green. Added, all verified by neutering the code and watching them fail: - `storage_cleanup_tests` (no Docker): destroy removes the work dir, still removes it when the provider destroy fails, leaves agent-run dirs alone, and neither create-failure arm strands one. Uses a real nested work dir so a recursive→shallow delete regression fails too. - `create_binds_mount_the_volume_destroy_will_remove` (no Docker): pins the bind `create` actually hands Docker, which is where the original leak lived. Re-inlining the run_id name would previously pass everywhere except an e2e that skips without a daemon. - `sandbox_service_and_provider_agree_on_volume_naming`: pins the cross-crate half — the service picks the container label, the provider turns it into a volume name, and nothing typed connects them. - `generated_names_never_reenter_the_pre_fix_namespace`: the guard for the security fix above. - The e2e now asserts the volume carries the label and that the container actually mounts it. Without the mount assertion, pre-creating the volume made "the volume exists" pass on its own — dropping the bind entirely went undetected. It also gained the `temps serve` skip guard its sibling tests have, pre-cleans both volume names, and captures observations before teardown so a failure can't leak a container onto the host.
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.
Problem
Destroying a sandbox removed its container but left storage behind. On a host that creates and destroys sandboxes — the normal usage pattern — this accumulates until the disk fills, and none of it is reachable through any API.
Two leaks, both on the standalone sandbox path:
1. The Docker home volume, on every delete.
createnamed the volumetemps-sandbox-home-{config.run_id}whiledestroyrebuilt the name by stripping the prefix off the container name. Those agree for agent runs (temps-sandbox-<run_id>) — which is exactly why this stayed invisible. But standalone sandboxes override the container suffix with their opaquepublic_idlabel, so destroy asked Docker to removetemps-sandbox-home-<hex>, a volume that had never existed. The 404 was swallowed astracing::warn!("...may not exist")and the real volume stayed on disk permanently.That single
remove_volumecall was the only one in the entire backend.2. The host work dir, on every delete.
create_sandboxmakesdata_root/<public_id>and bind-mounts it to/workspace. Nothing ever removed it — there was noremove_dir_allanywhere intemps-sandbox. Every destroyed sandbox stranded its full working tree:node_modules, build output, cloned repos.Fix
Volume naming. Both sides now go through one
DockerSandboxProvider::sandbox_names, which returns the container name and the home volume name together;destroyre-derives the volume from the container name via the samehome_volume_name. They cannot drift again.This also closes a latent cross-tenant collision: standalone sandbox row 5 and agent run 5 previously shared
temps-sandbox-home-5, meaning one sandbox could read another user's shell history,~/.claudecredentials, and project files. 16-hex labels can never equal a decimalrun_id, so the collision is now structurally impossible.Work dir. Removed on destroy and on both create-failure paths. The seed-source failure arm matters most: the directory can already hold a full clone, and the row is marked destroyed, so no later
destroy_sandboxcould ever reach it. Guarded by asbx_<16 hex>shape check viawork_dir_to_remove, becausedata_root.join()silently resolves..and absolute paths and this is a recursive delete.Volume label. Home volumes are stamped
sh.temps.sandbox.homeat create. Nothing in the server reads it — it exists so an operator can reclaim volumes stranded by older builds:pruneonly touches volumes no container references, and a label can't collide with something the operator created themselves.What this PR deliberately does not do
An earlier revision added a background sweep to reclaim already-leaked storage. Three review rounds found three different ways for it to delete the wrong thing:
createopens by force-removing the old container first — minutes wide on a cold pull.sandboxesrows areON DELETE CASCADEfromusers, so deleting a user hard-deletes the rows while the containers keep running — and the next sweep would recursively delete live workspaces.The pattern is the design, not the details. A sweep has to infer which storage is garbage; an explicit destroy is told. Each predicate we added — dangling, labelled, name-shaped, unclaimed — narrowed the wrong answers without making them impossible, and the failure mode is silent and irreversible.
Reclaiming the existing backlog is now an operator action with the prune command above. A safer automatic sweep can be designed separately, with instance identity and a provider cross-check as requirements rather than afterthoughts.
Evidence
Run against a live Docker daemon, not mocks.
The e2e fails on the old naming. Reverting just the create-side line:
That 404 is the bug — create had made
temps-sandbox-home-99993instead. Restored:The leak was real on the dev host. Eight
temps-sandbox-home-*volumes had accumulated, five of them referenced by no container at all.Suites:
Tests added
destroy_frees_the_home_volume_of_a_standalone_sandbox— Docker e2e. Asserts on the volume, not the container, since the container was always removed correctly. Also asserts the numeric-named volume was never created, so it can't pass by accident.sandbox_names_agree_for_standalone_and_agent_run— unit, no Docker. The regression guard that works on CI runners without a daemon; the first revision's unit tests all stayed green on the buggy code, which a review caught.home_volume_names_carry_the_documented_prefix— unit.work_dir_to_remove_targets_the_directory_create_allocated/..._refuses_ids_that_escape_the_data_root— unit; the traversal guard in front of the recursive delete.Docker-gated tests skip gracefully with no
#[ignore], per project policy.Upgrade note
Sandboxes created before this change keep their old
temps-sandbox-home-<row.id>volume while their container exists, and it becomes reclaimable once they're destroyed. The cross-tenant collision described above is closed for new sandboxes but not remediated for existing ones — recreating them is the operator action. Worth a release note.No API surface changed, so no
@temps-sdk/cliparity is needed.Post-review round 3 (commit
076abddc)A third review pass — security audit + test-coverage audit — found one security issue and a test that passed for the wrong reason.
Security: generated names could re-enter the pre-fix namespace
The naming fix wasn't retroactive in a way that mattered. On an upgraded host, a pre-fix standalone sandbox's volume is stranded (destroy computes the new name and misses) but stays on disk as
temps-sandbox-home-<sandboxes.id>. Agent runs still name theirstemps-sandbox-home-<agent_runs.id>, and Docker attaches an existing volume by name — so the next agent run whose id matched would silently mount a previous standalone sandbox's/home/temps: another user's Claude credentials, shell history and project state, read-write.Same root cause as the original collision: two independent id sequences sharing one namespace. Every name this build generates now carries a
v2-scheme marker, so it can't land on a pre-fix name and stranded legacy volumes are inert. Cost is one fresh home for a container recreated across the upgrade — agent-run homes are ephemeral, and standalone containers aren't recreated in place (stop/start/restart reuse the container).Other fixes
create_volumedoesn't retro-apply one — so the label-filtered prune matched none of the volumes I advertised it for. Documented the prefix +dangling=truecommand for those.createcan fail afterstart_container(ownership normalisation propagates its error) and containers arerestart-unless-stopped, so it could leave a live container holding the caller's env vars, unreachable once the row is destroyed, with its/workspacedeleted underneath it. Now destroys first, matching the seeding arm.create_volumeto just beforecreate_container, so a caller looping failed creates with a bogus image can't mint a volume per attempt.Tests
The coverage audit's finding: nothing covered the call sites, only the pure helpers. Deleting the
remove_work_dircalls left every test green. Each new test below was verified by neutering the production code and watching it fail.storage_cleanup_tests(no Docker,MockDatabase+ fake provider): destroy removes the work dir; still removes it when the provider destroy fails; leaves agent-run dirs alone; neither create-failure arm strands one. Uses a real nested work dir, so a recursive→shallow delete regression also fails. Verified: neutering the 3 call sites turns 4 of 5 red; hoisting cleanup above the agent-run early return turns the 5th red.create_binds_mount_the_volume_destroy_will_remove(no Docker): pins the bindcreateactually hands Docker — where the original leak lived. Re-inlining therun_idname previously passed everywhere except an e2e that skips without a daemon.sandbox_service_and_provider_agree_on_volume_naming: pins the cross-crate half — the service picks the container label, the provider turns it into a volume name, nothing typed connects them.generated_names_never_reenter_the_pre_fix_namespace: the guard for the security fix above.temps serveskip guard its siblings have, pre-cleans both volume names, and captures observations before teardown so a failure can't leak a container onto the host.On that last point — the guard fired for real during this work: a container leaked by an earlier deliberately-failed run was still running and correctly caused a skip.
Deliberately not added
A full Postgres + Docker integration test was considered.
sandbox_service_and_provider_agree_on_volume_namingcovers the same cross-crate identity agreement deterministically, in microseconds, with no daemon or container — and the existing e2e already proves the Docker half. The integration test's unique remaining value was small relative to the CI weight and flakiness it would add.