fix(sandbox): surface teardown failures and exit non-zero when removal fails#429
Merged
Merged
Conversation
…l fails destroySandbox and prune wrote "if (r.code === 0) removed.push(x)" at all four removal sites and discarded every non-zero docker result. DestroyResult and PruneResult carried no failure field at all, and cli.ts returned 0 for both verbs unconditionally. A daemon outage, a permission error, or an in-use object therefore left UNTRUSTED sandbox containers and source volumes running while automation read exit 0 - a containment failure, not a cosmetic one, and false precisely on the path where teardown matters. Both verbs now retain every failed docker operation in a bounded failures list (failureCount stays exact past the bound), and the CLI exits non-zero with a stderr diagnostic that NAMES every object left behind. Three properties beyond "record the error": - Best-effort, never aborting. A failure no longer influences what else is attempted: every discovered container and volume is still targeted, so a partial teardown reclaims everything it can. - Verified, not assumed. After the removals both verbs re-list the same label scope and report what is STILL PRESENT (remainingContainers / remainingVolumes). A kept volume is a deliberate survivor and is excluded, so --keep-volume is still a clean exit 0. - A failed LISTING is itself a failure. Discovery and verification route through new listContainersResult / listVolumesResult in registry.ts, which retain docker's exit code - "listed nothing while the daemon was down" must never read as "nothing is left". listContainers / listVolumes are unchanged thin wrappers over them, so no read-only caller changes behavior. teardown.test.ts drives the real destroy/prune (and the real runCli) over an injected STATEFUL fake docker in which a successful rm actually removes the object from later label listings - covering mixed success/failure, a dead daemon, complete success, the retention bound, and the CLI exit code for both verbs. lifecycle.test.ts's fake gains the same statefulness, because a fake whose listings are frozen in time would report every removed object as a leak and assert a physically impossible docker. sandbox.js is rebuilt from cli.ts (committed esbuild artifact). ca-sandbox advances to 0.1.5 with its own CHANGELOG section. Closes #393 Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
This was referenced Jul 25, 2026
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.
Closes #393
The defect, verified against source before any edit
All four claims in the issue held on current
origin/main(4ec1a8a, post-#425):destroy.ts:69if (r.code === 0) removedContainers.push(c);destroy.ts:81if (r.code === 0) removedVolumes.push(v);destroy.ts:113if (r.code === 0) removedContainers.push(c);destroy.ts:119if (r.code === 0) removedVolumes.push(v);cli.ts:368-377destroyandprunebothreturn 0;unconditionallyDestroyResult/PruneResultcarried no failure field at all, so a non-zero docker result was not merely unreported — it was unrepresentable. A daemon outage, a permission error, or an in-use object left untrusted sandbox containers and source volumes running while automation received exit 0. That is a containment failure: the teardown guarantee was false precisely on the path where cleanup matters.The fix
Beyond "record the error", three properties:
remainingContainers/remainingVolumes). A--keep-volumevolume is a deliberate survivor and is excluded from that set, so keeping a volume is still a clean exit 0.listContainersResult/listVolumesResultinregistry.ts, which retain docker's exit code. Without this, a fully dead daemon discovers nothing, removes nothing, and reports a clean sweep — the exact false-negative the issue is about.listContainers/listVolumesbecome unchanged thin wrappers, so no read-only caller changes behavior.Surfacing:
failuresis bounded (MAX_TEARDOWN_FAILURES = 25) whilefailureCountstays exact, so a wedged daemon cannot flood stdout/stderr yet the report never understates the damage.teardownIncomplete()is the verdict;formatTeardownDiagnostic()renders a bounded stderr report naming each failed op with docker's own exit code and message, then every object still present and the manualdocker rm -f/docker volume rmneeded. The full structured result still goes to stdout as JSON for scripting. New exit codeTEARDOWN_FAILURE_EXIT = 1, distinct from2(usage error).The verbs still return rather than throw — the exit-code decision belongs to the CLI, so a library caller can inspect a partial teardown instead of catching.
Red test first — verbatim failure output
teardown.test.tswas written and run before any source change. It drives the realdestroySandbox/pruneand the realrunCliover an injected stateful fake docker (a successfulrmactually removes the object from later label listings), so the post-teardown verification is exercised honestly rather than against a listing frozen in time.Each failure is on the defect itself — the missing failure surface (
undefinedwhere afailures/failureCount/remaining*field must be) and the exit-0 path (expected +0 not to be +0) — not on an import or a compile error. The 3 that passed pre-fix are the clean-teardown cases, which correctly exited 0 already; they are kept so the fix cannot be a blanket "always fail".Before / after
4ec1a8a)plugins/ca-sandbox/tools—npx vitest run(docker-gated)teardown.test.ts)plugins/ca-sandbox/tools—npx tsc --noEmitplugins/ca/hooks/tests—python -m unittest.github/scripts—python -m unittestpython tools/build-host-packages.py --check --release-guard-base <merge-base>python3 .github/scripts/check-plugin-refs.py ca-sandboxsandbox.jsstaleness gate (rebuild +git diff --quiet)Baseline
209is arithmetic-exact, not estimated: the only test file added isteardown.test.ts(16 cases); the edits tocli.test.tsandlifecycle.test.tsadd assertions to existing cases, never new ones.The 4 non-green
.github/scriptsresults are pre-existing and environmental, identical before and after this branch:test_pi_benchmarkrequiresplugins/ca-pi/tools/node_modules(the pinned esbuild), which is not installed in this worktree — the harness itself raisesRuntimeError: Pi benchmark requires the installed pinned esbuild. Nothing in this diff touches Pi.Test changes to pre-existing files, and why
lifecycle.test.ts'sfakeDockerreturned scripted stdout per arg-match, so its label listings were frozen in time: a removed object still appeared in every later listing. That was invisible while teardown never re-listed. Now that teardown verifies its own work, such a fake would report every successfully removed object as a leak and would lock in a physically impossible docker. ItsfakeDockernow models removal (a successfulrmdrops the ref from later listings) — the same discipline the new suite uses. Existing assertions are untouched and still pass; three clean-teardown cases gainedfailureCount/remaining*assertions.cli.test.ts'sfakeHandlersgained the new success-shape fields (they are required by the widened result types).Prose
commands/sandbox-destroy.mdandskills/sandbox-lifecycle/SKILL.mdstate the new contract as a hard gate: a teardown may not report success when docker refused a removal or the verification could not confirm the scope is empty. The prose previously promised "zero labeled objects remain" with no statement of what happens when that promise cannot be kept.Payload version
ca-sandbox0.1.4 -> 0.1.5with a new dated CHANGELOG section. Tagca-sandbox-v0.1.4is unpublished, so the CI guard would have tolerated staying on0.1.4; a distinct section was chosen so this behavioral change is not filed under #424's "supply-chain hardening" heading.NEEDS-TRIAGE (not in this diff)
create.ts's clone-failure rollback has the same shape. On a failed clone it firesdocker rm -f/volume rm -ffor the half-built sandbox and ignores the exit codes (create.ts~L451), so a rollback that docker refuses leaves a labeled volume behind and the caller only sees the clone error. Less severe than Surface and fail sandbox teardown when Docker removal fails #393 (the object is labeled, soprunecan still reclaim it, and no container is running untrusted code yet) but it is the same silent-drop pattern. Deliberately out of scope — Surface and fail sandbox teardown when Docker removal fails #393 namesdestroy.tsandcli.tsonly.registry.ts:labelValueswallows a failed inspect (if (r.code !== 0) return ""), so a container whose inspect fails is silently grouped under the leaked/no-id bucket inlistSandboxes. Read-only surface, no containment impact, but it makes a docker error look like a legitimately unlabeled object.destroy/prunenever re-attempt a failed removal. A volume that is momentarily in-use fails once and is reported as remaining. A bounded retry (or removing containers, waiting, then volumes) would cut the false-leak rate. Explicitly not added here: it is a behavior change beyond the issue, and a wrong retry policy on a wedged daemon is worse than an honest failure report.https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L