diff --git a/README.md b/README.md index 10c0da4..93d72ee 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Apple Dev Container CLI (adevcontainer) [![CI](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml/badge.svg)](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml) -[![tests](https://img.shields.io/badge/tests-1093%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) +[![tests](https://img.shields.io/badge/tests-1105%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Native Swift CLI that reads `devcontainer.json` and runs development environments on Apple [`container`](https://github.com/apple/container). diff --git a/Sources/ADevContainerLib/Commands/PurgeCommand.swift b/Sources/ADevContainerLib/Commands/PurgeCommand.swift index 8a2a36a..71cd573 100644 --- a/Sources/ADevContainerLib/Commands/PurgeCommand.swift +++ b/Sources/ADevContainerLib/Commands/PurgeCommand.swift @@ -8,12 +8,17 @@ public enum PurgeCommand { /// (`config_volumes`, `workspace_volume`); image from runtime inspect. /// Candidate volumes are deleted only when unreferenced after target container delete. /// - /// - Returns: `0` if all resources handled or already gone; `1` if a delete of an existing resource failed. + /// On a TTY, confirms before deletion (default no). Non-TTY proceeds without a prompt. + /// Recovery helpers are skipped with no prompt and no deletes. + /// + /// - Returns: `0` if all resources handled, already gone, or the TTY confirmation was declined; `1` if a delete of an existing resource failed. @discardableResult public static func run( name: String? = nil, runtime: AppleContainerRuntime, - picker: InteractivePicker = .default + picker: InteractivePicker = .default, + isInteractive: Bool = AppleContainerConfig.stdinIsTTY(), + readLine: @escaping () -> String? = { Swift.readLine() } ) throws -> Int32 { let info = try ManagedContainers.resolveSelection( name: name, @@ -30,6 +35,16 @@ public enum PurgeCommand { return 0 } + if isInteractive { + let prompt = "Purge dev container \(info.id) and its unreferenced volumes and image? [y/N] " + FileHandle.standardError.write(Data(prompt.utf8)) + let answer = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if answer != "y" && answer != "yes" { + print("Purge cancelled") + return 0 + } + } + let target = PurgeTarget( container: info, expectedContainerName: info.name, diff --git a/Sources/ADevContainerLib/Support/CommandSurface.swift b/Sources/ADevContainerLib/Support/CommandSurface.swift index c5f781b..dcda3c6 100644 --- a/Sources/ADevContainerLib/Support/CommandSurface.swift +++ b/Sources/ADevContainerLib/Support/CommandSurface.swift @@ -273,7 +273,7 @@ public enum CommandSurface { exec [-it] [--name] [--] [cmd...] Run a command (or shell) in a managed dev container stop [--name] Stop a managed dev container (name or picker) delete [--name] Remove container only (not workspace volume) - purge [--name] Remove container, volumes (incl. *-ws), and config image + purge [--name] Remove container, volumes (incl. *-ws), and config image; confirms on a TTY rebuild [--name] Force-rebuild a managed dev container from its current config (same name; volumes preserved) inspect [--name] Show identity, state, labels (from runtime + labels) @@ -494,6 +494,7 @@ public enum CommandSurface { Remove managed container, config named volumes (label), volume-mode workspace volume (*-ws), and config image. Selection via --name/picker. + On a TTY, confirms before deletion (default no). Non-TTY proceeds without a prompt. """ case "inspect": return """ diff --git a/Tests/adevcontainerTests/AllCommandTests.swift b/Tests/adevcontainerTests/AllCommandTests.swift index 4d365c6..985d45e 100644 --- a/Tests/adevcontainerTests/AllCommandTests.swift +++ b/Tests/adevcontainerTests/AllCommandTests.swift @@ -1886,9 +1886,210 @@ nonisolated(unsafe) let lifecycleTests: [(String, () throws -> Void)] = [ !mock.calls.contains { $0.arguments == ["volume", "delete", "other-vol"] }, "volumes not in labels must never be deleted" ) - }) + }), + ("purgeInteractiveDeclineSkipsDeletes", { + let previousEnabled = StatusPrinter.enabled + let previousPhase = StatusPrinter.hasEmittedPhase + defer { + StatusPrinter.enabled = previousEnabled + StatusPrinter.hasEmittedPhase = previousPhase + } + StatusPrinter.enabled = true + let id = "adev-purge-confirm" + let prompt = "Purge dev container \(id) and its unreferenced volumes and image? [y/N] " + let answers: [String?] = [nil, "", " ", "n", "no", "N", "NO", "maybe"] + for answer in answers { + let (runtime, mock) = makePurgeConfirmRuntime(id: id) + var stdout = "" + var stderr = "" + #if canImport(Darwin) || canImport(Glibc) + fflush(nil) + #endif + try withCapturedStdout({ + try withCapturedStderr({ + let code = try PurgeCommand.run( + name: id, + runtime: runtime, + isInteractive: true, + readLine: { answer } + ) + #if canImport(Darwin) || canImport(Glibc) + fflush(nil) + #endif + try MiniTest.expectEqual(code, 0, "decline \(String(describing: answer))") + }, capture: &stderr) + }, capture: &stdout) + try MiniTest.expectEqual(stderr, prompt, "stderr for \(String(describing: answer))") + try MiniTest.expect(!stderr.contains("Purging"), "no purging phase for \(String(describing: answer))") + try MiniTest.expect(!stderr.contains("Deleting"), "no deleting phase for \(String(describing: answer))") + try MiniTest.expectEqual(stdout, "Purge cancelled\n", "stdout for \(String(describing: answer))") + try MiniTest.expect( + !mock.calls.contains { ["delete", "volume", "image"].contains($0.arguments.first ?? "") }, + "no deletes for \(String(describing: answer))" + ) + } + }), + ("purgeInteractiveYesDeletes", { + let previousEnabled = StatusPrinter.enabled + let previousPhase = StatusPrinter.hasEmittedPhase + defer { + StatusPrinter.enabled = previousEnabled + StatusPrinter.hasEmittedPhase = previousPhase + } + StatusPrinter.enabled = true + let id = "adev-purge-confirm" + let prompt = "Purge dev container \(id) and its unreferenced volumes and image? [y/N] " + for answer in ["y", "Yes", " y "] { + let (runtime, mock) = makePurgeConfirmRuntime(id: id) + var stderr = "" + let code = try withCapturedPurgeStderr(capture: &stderr) { + try PurgeCommand.run( + name: id, + runtime: runtime, + isInteractive: true, + readLine: { answer } + ) + } + try MiniTest.expectEqual(code, 0, "yes \(answer)") + try MiniTest.expect(stderr.contains(prompt), "prompt for \(answer)") + try MiniTest.expect(stderr.contains("Purging"), "purging phase for \(answer)") + if let promptAt = stderr.range(of: prompt), let purgingAt = stderr.range(of: "Purging") { + try MiniTest.expect(promptAt.upperBound <= purgingAt.lowerBound, "prompt before purging for \(answer)") + } + try MiniTest.expect(purgeConfirmDeleted(mock, id: id), "deletes run for \(answer)") + } + }), + ("purgeNonInteractiveProceedsWithoutPrompt", { + let id = "adev-purge-confirm" + let (runtime, mock) = makePurgeConfirmRuntime(id: id) + var read = false + var stderr = "" + let code = try withCapturedPurgeStderr(capture: &stderr) { + try PurgeCommand.run( + name: id, + runtime: runtime, + isInteractive: false, + readLine: { + read = true + return "n" + } + ) + } + try MiniTest.expectEqual(code, 0) + try MiniTest.expect(!read, "non-TTY must not read a line") + try MiniTest.expect(!stderr.contains("Purge dev container"), "non-TTY must not prompt") + try MiniTest.expect(purgeConfirmDeleted(mock, id: id)) + }), + ("purgeInteractiveSkipsRecoveryHelperWithoutPrompt", { + let id = "recovery-helper" + let (runtime, mock) = makePurgeConfirmRuntime(id: id, recoveryHelper: true) + var read = false + var stderr = "" + let code = try withCapturedPurgeStderr(capture: &stderr) { + try PurgeCommand.run( + name: id, + runtime: runtime, + isInteractive: true, + readLine: { + read = true + return "y" + } + ) + } + try MiniTest.expectEqual(code, 0) + try MiniTest.expect(!read, "recovery helper skip must not prompt") + try MiniTest.expect(!stderr.contains("Purge dev container")) + try MiniTest.expect( + !mock.calls.contains { ["delete", "volume", "image"].contains($0.arguments.first ?? "") }, + "recovery helper skip must not delete" + ) + }), + ("purgeHelpMentionsTTYConfirmation", { + let usage = CommandSurface.usageText() + try MiniTest.expect(usage.contains("purge [--name]")) + try MiniTest.expect(usage.contains("confirms on a TTY")) + let help = CommandSurface.commandHelpText("purge") ?? "" + try MiniTest.expect(help.contains("TTY")) + try MiniTest.expect(help.contains("confirms before deletion")) + try MiniTest.expect(help.contains("Non-TTY proceeds without a prompt")) + try MiniTest.expect(!help.contains("--yes")) + try MiniTest.expect(!usage.contains("--yes")) + }), ] +private final class PurgeConfirmDeleted: @unchecked Sendable { + var value = false +} + +private func makePurgeConfirmRuntime( + id: String = "adev-purge-confirm", + recoveryHelper: Bool = false +) -> (runtime: AppleContainerRuntime, mock: MockProcessRunner) { + var labels: [String: String] = [ + ContainerIdentity.labelManaged: ContainerIdentity.managedValue, + ContainerIdentity.labelConfigVolumes: "vol-confirm", + ] + if recoveryHelper { + labels[RecoveryHelper.recoveryMarkerLabel] = RecoveryHelper.recoveryMarkerValue + } + let entry = MockProcessRunner.containerListJSON( + id: id, state: "stopped", labels: labels, image: "alpine:3.20" + ) + let volumeListData = try! JSONSerialization.data( + withJSONObject: [["id": "vol-confirm"]] as [[String: Any]] + ) + let deleted = PurgeConfirmDeleted() + let mock = MockProcessRunner() + mock.handlers = [ + { args in + if args.starts(with: ["list"]) { + let payload: [Any] = deleted.value ? [] : [entry] + let data = try! JSONSerialization.data(withJSONObject: payload) + return ProcessResult(exitCode: 0, stdout: data, stderr: Data()) + } + if args.first == "delete" { + deleted.value = true + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args == ["volume", "list", "--format", "json"] { + return ProcessResult(exitCode: 0, stdout: volumeListData, stderr: Data()) + } + if args.starts(with: ["volume", "delete"]) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + if args.starts(with: ["image", "delete"]) || args.starts(with: ["image", "rm"]) { + return ProcessResult(exitCode: 0, stdout: Data(), stderr: Data()) + } + return nil + } + ] + let runtime = AppleContainerRuntime(executablePath: "/usr/local/bin/container", runner: mock) + return (runtime, mock) +} + +private func purgeConfirmDeleted(_ mock: MockProcessRunner, id: String) -> Bool { + let deletedContainer = mock.calls.contains { $0.arguments.first == "delete" && $0.arguments.contains(id) } + let deletedVolume = mock.calls.contains { $0.arguments == ["volume", "delete", "vol-confirm"] } + let deletedImage = mock.calls.contains { + $0.arguments == ["image", "delete", "alpine:3.20"] || $0.arguments == ["image", "rm", "alpine:3.20"] + } + return deletedContainer && deletedVolume && deletedImage +} + +private func withCapturedPurgeStderr( + capture: inout String, + _ body: () throws -> Int32 +) throws -> Int32 { + var code: Int32 = 1 + try withCapturedStderr({ + code = try body() + #if canImport(Darwin) || canImport(Glibc) + fflush(nil) + #endif + }, capture: &capture) + return code +} + /// Container list JSON with real volume mounts (RecoveryHelperTests shape) for purge attachment mocks. private func purgeAttachedContainerJSON( id: String, diff --git a/specs/managed-lifecycle.md b/specs/managed-lifecycle.md index 559819b..28eb6eb 100644 --- a/specs/managed-lifecycle.md +++ b/specs/managed-lifecycle.md @@ -104,6 +104,8 @@ If no container exists, inspect MUST fail structurally or report not-found consi | Bind-mount host paths | No | | Global volume/image prune | No | +Those removals, and the existing MUST-remove scenarios in this requirement, apply only after an explicit yes on an interactive TTY, or when stdin is not a TTY. A declined or unanswered TTY confirmation MUST NOT remove them. + Features-derived tags keep that existing purge policy. Dockerfile-only creates MUST stamp the product Dockerfile tag so purge can treat it as the config image to delete. **Identity and candidates (unchanged intent):** @@ -114,12 +116,21 @@ Features-derived tags keep that existing purge policy. Dockerfile-only creates M - Labels define the **candidate volume set only**. The product MUST NOT invent Compose `external` support, shared/private naming conventions, or new public `devcontainer.json` fields for this decision. - Same resolved volume name MUST be treated as the same volume (Docker-like sharing). No separate “private copy” identity. +**Confirmation gate (MUST):** + +- After managed selection, a recovery-helper skip MUST still exit 0 with no confirmation prompt and no deletes. +- On an interactive TTY, before any container, volume, or image delete and before the purging status, purge MUST write this raw stderr prompt (not StatusPrinter, not QUIET-gated), naming the container id: `Purge dev container and its unreferenced volumes and image? [y/N] `. +- Only `y` or `yes` (case-insensitive) MUST proceed into the existing purge path. +- Empty input, `n`/`no`, EOF, or any other answer MUST cancel: no container, volume, or image deletes; stdout `Purge cancelled`; exit 0; no deleting phases. +- Non-TTY MUST NOT prompt and MUST keep the existing purge behavior (proceed to delete). Non-TTY MUST NOT fail for lack of confirmation. This requirement MUST NOT add `--yes`. + **Ordering and fail-safe (MUST):** -1. Resolve the managed target (and apply recovery-helper skip first when applicable — unchanged: skip helper, its referenced volumes, and its image; exit 0). -2. Delete the **target container** (force as today). Missing container after resolve MAY be skipped as today. -3. **If target container delete fails** (runtime error on an existing container the command attempted to delete), the command MUST **not** delete any candidate volumes, MUST return non-zero, and MAY still attempt image cleanup only if that does not undermine the volume fail-safe (preferred: treat container failure as hard failure and skip volume deletes entirely). -4. Only after the target container is gone (deleted successfully or already absent), evaluate each **distinct** candidate volume name from labels (`config_volumes` + optional `workspace_volume`). +1. Resolve the managed target (and apply recovery-helper skip first when applicable — unchanged: skip helper, its referenced volumes, and its image; exit 0; no confirmation prompt and no deletes). +2. Apply the confirmation gate when stdin is a TTY. Decline exits 0 with no deletes. Non-TTY skips the prompt and continues. +3. Delete the **target container** (force as today). Missing container after resolve MAY be skipped as today. +4. **If target container delete fails** (runtime error on an existing container the command attempted to delete), the command MUST **not** delete any candidate volumes, MUST return non-zero, and MAY still attempt image cleanup only if that does not undermine the volume fail-safe (preferred: treat container failure as hard failure and skip volume deletes entirely). +5. Only after the target container is gone (deleted successfully or already absent), evaluate each **distinct** candidate volume name from labels (`config_volumes` + optional `workspace_volume`). **Volume attachment gate (MUST):** @@ -151,7 +162,8 @@ For each existing candidate volume name: | Condition | Volume deletes | Exit | |-----------|----------------|------| -| Recovery helper selected | None (full skip) | 0 | +| TTY confirmation declined (empty, n/no, EOF, or other) | None | 0 | +| Recovery helper selected | None (full skip; no prompt) | 0 | | Target container delete failed | None | non-zero | | Attachment inspection failed for a candidate | Preserve affected volume(s) | non-zero | | Shared volume(s) preserved with warning only | Skip those; delete unreferenced others | 0 if no hard failures | @@ -218,9 +230,21 @@ For each existing candidate volume name: - Then the command exits non-zero (hard failure) #### Scenario: Recovery helper skip unchanged -- Given a marked recovery helper selected for purge +- Given a marked recovery helper selected for purge, including on an interactive TTY - When the user runs `adevcontainer purge --name ` -- Then the helper, its referenced workspace/config volumes, and its image are not removed, and the command exits 0 +- Then the helper, its referenced workspace/config volumes, and its image are not removed, no confirmation prompt is shown, and the command exits 0 + +#### Scenario: TTY confirmation gates purge deletes +- Given a managed dev container selected for purge and an interactive TTY +- When the user runs `adevcontainer purge` targeting that container +- Then before any container, volume, or image delete and before the purging status, stderr shows `Purge dev container and its unreferenced volumes and image? [y/N] ` +- And only `y` or `yes` (case-insensitive) proceeds into the existing purge path +- And empty input, `n`/`no`, EOF, or any other answer cancels with stdout `Purge cancelled`, exit 0, and no deletes or deleting phases + +#### Scenario: Non-TTY purge proceeds without a prompt +- Given a managed dev container selected for purge and non-interactive stdin +- When the user runs `adevcontainer purge` targeting that container +- Then no confirmation prompt is shown and the existing MUST-remove behavior applies #### Scenario: delete remains container-only - Given any managed container diff --git a/wiki/architecture.md b/wiki/architecture.md index ee264a1..dc8d18c 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -75,7 +75,7 @@ Volume mode exists for better metadata I/O (git status, node_modules, many small | `exec [--name]` | Run command/shell in running managed container (`-it` / empty cmd → interactive TTY, default `bash`). Selection: `--name` or picker (no `-w`). User/workdir from labels `devcontainer.remote_user` / `devcontainer.workspace_folder` when stamped (new creates always stamp non-empty `remote_user` incl. `root`; empty = legacy — see [Connection user](#connection-user)) | | `stop [--name]` | Stop managed container (`--name` or picker; no `-w`) | | `delete [--name]` | Remove **container only** (`--name` or picker; no `-w`) | -| `purge [--name]` | Remove container **and** unreferenced candidate volumes (labels `config_volumes` / `workspace_volume` = candidates only; real mounts after target delete decide) **and** config image (`--name` or picker; not binds; not global prune; shared volumes preserved) | +| `purge [--name]` | Not an immediate delete on an interactive TTY. Confirmation contract: [`specs/managed-lifecycle.md`](../specs/managed-lifecycle.md). Resource set: container **and** unreferenced candidate volumes (labels `config_volumes` / `workspace_volume` = candidates only; real mounts after target delete decide) **and** config image (`--name` or picker; not binds; not global prune; shared volumes preserved) | | `inspect [--name]` | Show resolved identity/state (`--name` or picker; no `-w`) | **Selection:** only `up` takes `-w`/cwd (bind workspace). Lifecycle commands (`start`/`exec`/`stop`/`delete`/`purge`/`rebuild`/`inspect`) resolve managed containers by `--name` or interactive picker — never `-w`. @@ -95,7 +95,7 @@ Shared primitive `BringUpRecovery`. Rebuild hard post-delete recovery is separat | No offer | Config missing; clone fetch fails before any config exists. | | Prompt | TTY (no `--json`): `Open the recovery editor now? [Y/n]` default **Y**. Decline/EOF → original error. Non-TTY/`--json` never prompt. | -**delete vs purge:** `delete` drops the managed dev container only (no volumes). `purge` also removes the config `image` or stamped product Dockerfile tag and **unreferenced** candidate named volumes: labels `config_volumes` / `workspace_volume` are the **candidate set only**; after the target container is gone, real mounts on all remaining containers (running/stopped, managed or not — `containersAttached` / `list --all`) decide delete vs preserve. Shared/referenced volumes stay with a StatusPrinter warning listing referencers (share-only → exit 0). Container delete fail → no volume deletes; attachment inspect fail → preserve affected volume(s) + non-zero. Same volume name = shared Docker-like resource (labels on containers, not volumes). Neither deletes bind-mount host paths or runs global `volume`/`image` prune. Derived Features tags (`adev-{base}:{hash12}` / `adevcontainer:{hash12}`) are not removed by `purge` unless they equal the config `image` field. Recovery-helper purge skip unchanged. Contract: [`specs/managed-lifecycle.md`](../specs/managed-lifecycle.md); archive [`20260812-prune-shared-volume-safety`](../specs/changes/archive/20260812-prune-shared-volume-safety/). +**delete vs purge:** `delete` drops the managed dev container only (no volumes). Interactive `purge` is not an immediate delete. `purge` also removes the config `image` or stamped product Dockerfile tag and **unreferenced** candidate named volumes: labels `config_volumes` / `workspace_volume` are the **candidate set only**; after the target container is gone, real mounts on all remaining containers (running/stopped, managed or not — `containersAttached` / `list --all`) decide delete vs preserve. Shared/referenced volumes stay with a StatusPrinter warning listing referencers (share-only → exit 0). Container delete fail → no volume deletes; attachment inspect fail → preserve affected volume(s) + non-zero. Same volume name = shared Docker-like resource (labels on containers, not volumes). Neither deletes bind-mount host paths or runs global `volume`/`image` prune. Derived Features tags (`adev-{base}:{hash12}` / `adevcontainer:{hash12}`) are not removed by `purge` unless they equal the config `image` field. Recovery-helper purge skip unchanged. Contract (resource set and confirmation): [`specs/managed-lifecycle.md`](../specs/managed-lifecycle.md); archive [`20260812-prune-shared-volume-safety`](../specs/changes/archive/20260812-prune-shared-volume-safety/). **Progress:** StatusPrinter phases on stderr (`==> …`); internal tool tees framed ` | ` (hooks, Features build, clone populate `streamOutput`); connection hints are **info** (not `==> `) after successful `up`/`clone`/`start`/`rebuild` unless originating `--vscode`. User `exec` unframed; interactive TTY unchanged. QUIET silences phase/info only; warn/error/tool body emit. `--json` stdout pure. Full stack: [terminal-output.md](conventions/terminal-output.md); runtime tee notes: [cli-runtime-boundary — Progress/tee](conventions/cli-runtime-boundary.md#progress--tee). diff --git a/wiki/conventions/cli-runtime-boundary.md b/wiki/conventions/cli-runtime-boundary.md index 8ee6bef..018f83e 100644 --- a/wiki/conventions/cli-runtime-boundary.md +++ b/wiki/conventions/cli-runtime-boundary.md @@ -300,7 +300,7 @@ Shared primitive for `up`/`clone` (edit-and-retry) and `start` (rebuild handoff) ## `purge` resource set -`purge` removes container + config `image` + **unreferenced** candidate volumes. Labels define candidates only — **not** unconditional volume delete. +Interactive `purge` is not an immediate delete. Resource set: container + config `image` + **unreferenced** candidate volumes. Labels define candidates only — **not** unconditional volume delete. | Resource | Included? | |----------|-----------| @@ -314,7 +314,7 @@ Shared primitive for `up`/`clone` (edit-and-retry) and `start` (rebuild handoff) **Attachment gate (after target container deleted or already absent):** for each distinct candidate name, inspect real volume mounts on **all** remaining containers (managed or not, **running or stopped**) via `containersAttached` / `list --all` style inspection. Target does not count. **Unreferenced** + exists → delete. **Referenced (shared)** → preserve + stderr StatusPrinter warning listing referencers (prefer name+id); share-only is **not** a hard failure (exit 0 when no other hard fails). **Attach inspect fail** → preserve that volume + non-zero. Runtime rejection on volume delete remains hard-fail. Missing resources skipped. -**Model:** labels live on containers, not volumes; same volume name = shared resource (Docker-like). No Compose `external` / shared-private naming schema. `delete` stays container-only. Recovery-helper purge skip unchanged. Contract: [`specs/managed-lifecycle.md`](../../specs/managed-lifecycle.md); archive [`20260812-prune-shared-volume-safety`](../../specs/changes/archive/20260812-prune-shared-volume-safety/). +**Model:** labels live on containers, not volumes; same volume name = shared resource (Docker-like). No Compose `external` / shared-private naming schema. `delete` stays container-only. Recovery-helper purge skip unchanged. Contract (resource set and confirmation): [`specs/managed-lifecycle.md`](../../specs/managed-lifecycle.md); archive [`20260812-prune-shared-volume-safety`](../../specs/changes/archive/20260812-prune-shared-volume-safety/). ## Features runner