feat: volume-mode workspaces#404
Conversation
Add `workspace.mode: volume` (and `--workspace-mode volume`): copy the host working tree into an ephemeral per-run Docker named volume (`moat-ws-<run-id>`) instead of bind-mounting /workspace — for host protection and faster macOS I/O. Changes are extracted via snapshots (`moat snapshot` / `moat snapshot restore --to`), which include `.git` so commits made inside the volume are preserved. Builds on the named-volume primitive from #402 (`MountConfig.Volume`, `buildContainerMounts`, the `moat-init` entrypoint forcing, Apple rejection), adding the workspace layer on top: - copy-in from a read-only staging bind via `moat-init` (excludes honored, run as root, then chown + privilege drop) - ephemeral volume lifecycle: `VolumeCreate/Remove/List/Export` on the runtime - volume-aware snapshots (archive backend, `.git` included, 0600 archives) materialized via a `tar` export sidecar - guards: Docker-only, git worktree/submodule rejected, in-place restore blocked (requires `--to`), destroy/clean refuse a volume run with no extraction snapshot unless `--force` - daemon idle GC of orphaned `moat-ws-*` volumes - docs, unit tests, and a Docker-gated e2e lifecycle test
- VolumeExport: capture the source tar's exit status via a temp file and require both pipe ends to succeed. Previously the POSIX pipeline reported only the destination tar's status, so a source tar that exited non-zero while emitting a valid subset archive produced a silently truncated extraction snapshot (the same trap the populate path already guards). - Orphan-volume GC: list volumes before snapshotting live run dirs to close a TOCTOU where a run starting between the two reads could have its fresh volume seen-but-not-live and wrongly reclaimed. - Create cleanup: remove the workspace volume with context.Background() so a canceled caller ctx (Ctrl-C) cannot reject the cleanup and leak the volume. - VolumeRemove: treat "not found" as success, keeping destroy/GC idempotent and avoiding spurious warnings when both remove the same volume.
…; add coverage Fixes from the second review pass, each with a companion test: - Chown routing (#2): volume mode forces containerUser "0:0", but both volumeChownEnv and volumeOwnershipPlan only recognized "" as a root entrypoint. So a run combining workspace.mode: volume with a config type: volume volume skipped MOAT_VOLUME_CHOWN *and* ran the ownership helper, chowning the volume root to 0:0 (root) — leaving the run user unable to write it. Treat "0:0" as a root entrypoint in both places (only affects volume mode; #402's ""/UID paths are untouched). This also drops a redundant root->root helper round-trip the workspace volume triggered. - Destroy/clean guard (#3): hasExtractionSnapshot built a snapshot.Engine, which requires the run's workspace to still exist — but in volume mode the host workspace is typically gone by destroy time, so the guard failed closed and demanded --force even when a valid extraction snapshot existed. Add snapshot.ListSnapshots to read the store directly (no workspace) and use it. - VolumeExport timeout (#5): bound the export with a 30m context so a wedged container or stalled daemon can't hang `moat snapshot` forever. - Single-source the volume-name prefix (#7): export daemon.WorkspaceVolumePrefix and have run.WorkspaceVolumeName build from it (run imports daemon, not the reverse), removing the duplicated "moat-ws-" literal. Tests: 0:0 cases for volumeChownEnv and volumeOwnershipPlan; ListSnapshots without a workspace; hasExtractionSnapshot (pre-run/safety -> false, manual -> true, no store -> false) with no metadata/workspace on disk; liveRunIDsFromRunDirs run-dir enumeration.
#6: `moat clean` exited 0 whether it cleaned everything or quietly skipped volume runs lacking an extraction snapshot, leaving automation no signal that work was left behind. Return a non-zero exit (with an actionable message) when any run was skipped; SilenceUsage keeps it to a clean stderr line. --force is unchanged (it removes them). #8: remove duplicated literals that risked silent drift: - config.IsVolumeMode(string) replaces the `== string(WorkspaceModeVolume)` cast repeated across clean/snapshot/manager/volume. - snapshot.BackendArchive/BackendAPFS constants replace the "archive"/"apfs" literals in detectBackend and the three snapshot.go ForceBackend sites. Tests: IsVolumeMode (volume true; bind/empty/mis-cased false).
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 7m 30s —— View job Code Review
SummaryThis is a substantial, well-engineered feature. The author has clearly done careful thinking about the hard problems: the POSIX pipeline One definite bug and one design concern worth addressing before merge: Definite bug —
|
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 10m 24s —— View job Code Review
This is a large, well-considered feature. The TOCTOU fix in the GC (list volumes before snapshotting live run dirs), the pipeline exit-code capture pattern in moat-init.sh, the Bugs
Codex/Gemini local MCP The Codex/Gemini MCP setup blocks (lines ~305–322 in Low
Air-gapped environments, Docker Hub rate limits, or custom registries will cause
Trigger-based snapshots during a volume-mode run would snapshot the host staging directory
Looks good
|
| // TestVolumeCopyInPipeline exercises the tar pipeline that | ||
| // populate_workspace_volume() in moat-init.sh uses. The script passes | ||
| // --no-dereference (which is the default in GNU tar; the flag makes the intent | ||
| // explicit in debian:bookworm-slim containers). The CI host's GNU tar 1.34 | ||
| // does not recognize --no-dereference as a flag name, so the test pipeline | ||
| // omits it — the default behavior is identical. |
There was a problem hiding this comment.
Stale comment — contradicts TestMoatInitNoNoDeref (lines 155-161) and the actual script. The script does not pass --no-dereference (removed because GNU tar 1.34 in the container image rejects the flag). The comment here says "The script passes --no-dereference … the test pipeline omits it" — exactly the opposite of the truth.
| // TestVolumeCopyInPipeline exercises the tar pipeline that | |
| // populate_workspace_volume() in moat-init.sh uses. The script passes | |
| // --no-dereference (which is the default in GNU tar; the flag makes the intent | |
| // explicit in debian:bookworm-slim containers). The CI host's GNU tar 1.34 | |
| // does not recognize --no-dereference as a flag name, so the test pipeline | |
| // omits it — the default behavior is identical. | |
| // TestVolumeCopyInPipeline exercises the tar pipeline that | |
| // populate_workspace_volume() in moat-init.sh uses. The script does NOT pass | |
| // --no-dereference: that long option only exists in GNU tar 1.35+, but the | |
| // container base (debian bookworm) ships GNU tar 1.34, which rejects it. Symlink | |
| // preservation is tar's default behavior; no flag is needed. | |
| // | |
| // The pipeline under test: | |
| // | |
| // cd "$staging" && tar --exclude-from="$excludeFile" -cf - . \ | |
| // | ( cd "$workspace" && tar -xf - ) | |
| // | |
| // The exclude file uses the REAL emitted format: newline-delimited, | |
| // "./"-prefixed patterns (see run.workspaceExcludes). NUL delimiting is NOT | |
| // used — GNU tar 1.34's --null --exclude-from only applies the first record, | |
| // silently dropping the rest. |
The inline comment on line ~68–71 ("Pipeline without --no-dereference … the script keeps the flag for documentation clarity") is also stale for the same reason — the script does not keep the flag. Fix this →
| // Pipeline without --no-dereference: the CI host's GNU tar 1.34 doesn't | ||
| // recognize that flag name (it is the default and has no explicit flag). | ||
| // The container image (debian:bookworm-slim, GNU tar 1.35+) does accept it; | ||
| // the script keeps the flag for documentation clarity. |
There was a problem hiding this comment.
Second stale comment — same issue as the header. "the script keeps the flag for documentation clarity" is incorrect; the script omits the flag. TestMoatInitNoNoDeref below is the authoritative statement.
| // Pipeline without --no-dereference: the CI host's GNU tar 1.34 doesn't | |
| // recognize that flag name (it is the default and has no explicit flag). | |
| // The container image (debian:bookworm-slim, GNU tar 1.35+) does accept it; | |
| // the script keeps the flag for documentation clarity. | |
| // Pipeline mirrors what moat-init.sh runs: tar default behavior preserves | |
| // symlinks without --no-dereference (which GNU tar 1.34 rejects). |
| // named volume. Destroying without an extraction snapshot loses it, so | ||
| // `moat clean` honors the same guard as `moat destroy`: skip such runs | ||
| // unless --force was passed (mirrors destroy's --force override). | ||
| if !cleanForce && config.IsVolumeMode(r.WorkspaceMode) && !hasExtractionSnapshot(r.ID) { |
There was a problem hiding this comment.
--force now silently serves two distinct purposes: (1) skip the "N stopped runs — remove?" confirmation prompt (original), and (2) override the volume data-loss guard (new). A user who has developed the habit of moat clean --force to skip confirmations will accidentally destroy un-extracted volume workspaces.
moat destroy correctly used a separate destroyForce flag for exactly this reason. Consider the same approach here — either a separate --force-volumes flag, or at minimum update the flag description to call out the new effect: "skip confirmation prompt and override the data-loss guard for volume-mode runs". Fix this →
| fmt.Printf(" (%d worktree dirs skipped — run from within the repo to clean)", wtSkippedCount) | ||
| } | ||
| fmt.Println() | ||
|
|
||
| // Exit non-zero when volume runs were skipped to protect un-extracted work. | ||
| // The summary above is human-readable, but `moat clean` (without --force) | ||
| // otherwise exits 0 whether it cleaned everything or quietly skipped runs, | ||
| // giving automation no signal that work was left behind. A non-zero exit lets | ||
| // a pipeline notice and decide to snapshot-then-clean or re-run with --force. | ||
| if skippedCount > 0 { | ||
| return fmt.Errorf("%d volume run(s) skipped to avoid data loss; snapshot them (moat snapshot <run>) or re-run with --force", skippedCount) | ||
| } | ||
| return nil | ||
| } | ||
|
|
There was a problem hiding this comment.
The skipped-count info has already been printed in the summary line above ("(%d volume runs skipped — snapshot or --force to remove)"). Returning fmt.Errorf(...) causes cobra to print it a second time with an Error: prefix, producing:
Removed 0 runs... (1 volume runs skipped — snapshot or --force to remove)
Error: 1 volume run(s) skipped to avoid data loss; snapshot them (moat snapshot <run>) or re-run with --force
The human-readable summary is clear; the Error: line is redundant. If the non-zero exit is the goal, using os.Exit(1) after the fmt.Println() or a sentinel error type that cobra suppresses its message for would avoid the duplication. Low severity (it works), but slightly confusing in output.
| // VolumeList returns names of volumes whose name starts with prefix. | ||
| func (r *DockerRuntime) VolumeList(ctx context.Context, prefix string) ([]string, error) { | ||
| resp, err := r.cli.VolumeList(ctx, volume.ListOptions{ | ||
| Filters: filters.NewArgs(filters.Arg("label", "moat=workspace")), | ||
| }) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("list volumes: %w", err) | ||
| } | ||
| var names []string | ||
| for _, v := range resp.Volumes { | ||
| if v != nil && strings.HasPrefix(v.Name, prefix) { | ||
| names = append(names, v.Name) | ||
| } | ||
| } | ||
| return names, nil | ||
| } |
There was a problem hiding this comment.
The prefix parameter is received but the Docker API VolumeList is called with only a label filter, then the prefix is applied in Go. That's fine, but the VolumeList interface contract says "returns names of volumes whose name starts with prefix" — the label filter is an invisible extra constraint not in the signature. If someone calls this from a context where the volume was created without the moat=workspace label (e.g. from a pre-label version or a manual test), those volumes would be silently excluded despite matching the prefix.
Since all volumes are created via VolumeCreate which always adds the label, this is not a real bug today. But it's a latent footgun if the interface is reused. The doc comment on the Runtime interface should mention the label filter, or the label should be passed as a parameter. Minor.
| func GuardVolumeWorkspace(hostWorkspace string, rt container.RuntimeType) error { | ||
| if rt != container.RuntimeDocker { | ||
| return fmt.Errorf("volume mode requires the Docker runtime; set workspace.mode: bind or run with --runtime docker") | ||
| } | ||
| if info, err := os.Lstat(filepath.Join(hostWorkspace, ".git")); err == nil && !info.IsDir() { | ||
| return fmt.Errorf("volume mode does not support git worktrees or submodules (.git is a file at %s/.git); use the main checkout or workspace.mode: bind", hostWorkspace) | ||
| } | ||
| return nil |
There was a problem hiding this comment.
The guard checks os.Lstat(filepath.Join(hostWorkspace, ".git")) and rejects if .git is a file (worktree/submodule). But it silently allows the case where .git doesn't exist at all (non-git directory). The error message then reads "use the main checkout or workspace.mode: bind", implying a git repo context that may not apply.
This is probably fine since volume mode is useful for non-git workspaces too, but the error message could be clearer. Not a bug, just a minor message accuracy note.
|
|
||
| cfg := Config{ | ||
| Name: id.Generate("moat-export"), | ||
| Image: "debian:bookworm-slim", |
There was a problem hiding this comment.
debian:bookworm-slim is hard-coded. If the user's Docker daemon can't reach Docker Hub (air-gapped env, custom mirror, or rate-limiting), moat snapshot <run> fails with an opaque pull error rather than a helpful message. The image also has to match the architecture of the Docker host. Consider making this configurable (e.g., via global config or an env var with a documented override), or at least surfacing the image name in the error when pull fails so users know what to mirror.
| fi | ||
|
|
||
| # The fresh volume mountpoint is root-owned; hand it to the agent user. | ||
| chown -R moatuser:moatuser /workspace |
There was a problem hiding this comment.
For volume mode + Codex or Gemini with local process MCP servers (codex.mcp: / gemini.mcp: in moat.yaml), there's a sequencing issue: the Codex/Gemini MCP setup sections (around lines 305–320) write /workspace/.mcp.json to the (empty) volume before populate_workspace_volume runs. When populate_workspace_volume copies the staging tree over, it extracts the host workspace contents — and if the user's project already has a .mcp.json, that file overwrites the moat-injected one.
In bind mode, moat's cp wins because it runs on the live bind mount; in volume mode, the host copy wins. For users who have an existing .mcp.json in their project and use volume mode with Codex/Gemini MCP servers, the moat-injected MCP config is silently discarded.
Fix: move the Codex/Gemini .mcp.json copy to run after populate_workspace_volume (i.e., between populate_workspace_volume and run_pre_run_hook), or add a MOAT_WORKSPACE_VOLUME guard around the copy.
| // the archive backend to find/delete them (auto-detect would pick APFS on macOS). | ||
| pruneOpts := snapshot.EngineOptions{} | ||
| if config.IsVolumeMode(meta.WorkspaceMode) { | ||
| pruneOpts.ForceBackend = snapshot.BackendArchive |
There was a problem hiding this comment.
pruneSnapshots correctly forces BackendArchive here, but listSnapshots (line 243, not modified in this PR) uses snapshot.EngineOptions{} and auto-detects the backend. On macOS, auto-detect picks APFS, so engine.Backend().Name() in the listSnapshots footer prints "apfs" instead of "archive" for volume-mode runs. The listing itself works (reads from the JSON metadata file), but the footer is misleading and the inconsistency is a maintenance footgun.
Same fix as here: check config.IsVolumeMode(meta.WorkspaceMode) and set ForceBackend: snapshot.BackendArchive in listSnapshots. Fix this →
…rce split, list backend) - B1: in volume mode the Codex/Gemini local-MCP `.mcp.json` was written to the fresh volume before populate_workspace_volume tar-extracted the staging tree over /workspace, so the user's own .mcp.json could clobber moat's injected config (the reverse of bind mode). Move the copy into setup_workspace_mcp_json and call it after populate so moat's config wins in both modes. - D1: split `moat clean` --force. --force now only skips the confirmation prompt; a new --force-volumes is required to remove volume-mode runs that lack an extraction snapshot. Previously a `clean --force` script silently bypassed the data-loss guard. Adds shouldSkipVolumeRunForClean + companion test and updates the CLI reference and volume guide. - P1: clean sets SilenceErrors so the skipped-volume summary line and the non-zero-exit error no longer double-print. - B3: listSnapshots forces the archive backend for volume-mode runs (matching prune/restore) so the printed backend name isn't "apfs" on macOS. - B2: fix stale comments in moat_init_volume_test.go that claimed the script passes --no-dereference; it does not (GNU tar 1.34; symlink-preserve is the default), as TestMoatInitNoNoDeref asserts.
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 5m 58s —— View job Code Review
SummaryThis is a well-engineered, well-tested feature. The author addressed every issue flagged by previous reviews before this push:
The core invariants are all correct: GC list-before-live ordering prevents TOCTOU, pipeline exit-status capture ( One definite bug worth fixing before merge: Bug —
|
|
Addressed the review-bot findings in
Lower-priority items left as follow-ups (noted, not blocking): All unit tests (race) + the Docker e2e lifecycle pass; lint clean. |
| if config.IsVolumeMode(meta.WorkspaceMode) { | ||
| restoreOpts.ForceBackend = snapshot.BackendArchive | ||
| } | ||
| engine, err := snapshot.NewEngine(meta.Workspace, snapshotDir, restoreOpts) |
There was a problem hiding this comment.
Bug: volume-mode snapshot restore fails when original workspace is gone.
NewEngine statically validates that meta.Workspace exists on disk (engine.go:50). For a --to restore of a volume-mode run, the workspace is never read — RestoreTo only opens the archive file at meta.NativeRef and extracts it to destPath. But if the user deleted or renamed the original source directory after their volume-mode run (a natural workflow: agent ran in isolation, host tree is no longer relevant), moat snapshot restore <run> --to ~/recovered fails with "workspace does not exist: /original/path".
Same issue affects listSnapshots and pruneSnapshots (both call NewEngine(meta.Workspace, ...)).
Fix: for volume-mode runs in the --to branch (and for list/prune), substitute a path that is guaranteed to exist — os.TempDir() works since the engine only needs the workspace to pass the existence check; it never reads from it during RestoreTo, List, or Delete.
engineWorkspace := meta.Workspace
if config.IsVolumeMode(meta.WorkspaceMode) && snapshotRestoreTo != "" {
// RestoreTo reads only the archive; workspace is never accessed.
// Use a stable path in case the original source dir is gone.
engineWorkspace = os.TempDir()
}
engine, err := snapshot.NewEngine(engineWorkspace, snapshotDir, restoreOpts)|
|
||
| cfg := Config{ | ||
| Name: id.Generate("moat-export"), | ||
| Image: "debian:bookworm-slim", |
There was a problem hiding this comment.
Low: debian:bookworm-slim is hardcoded and will fail in air-gapped environments.
In private networks or behind corporate proxies without Docker Hub access, moat snapshot <run> fails with an opaque pull error that doesn't name the image or explain what to do. At minimum, include the image name in the error path when the container fails to start:
if startErr := r.StartContainer(ctx, ctrID); startErr != nil {
return fmt.Errorf("start export container (image: %s): %w", cfg.Image, startErr)
}Longer term, consider making it configurable via an env var or global config (moat.exportImage). Low priority since Docker Hub access is the common case, but worth a follow-up issue. Fix this →
| } | ||
|
|
||
| // VolumeList returns names of volumes whose name starts with prefix. | ||
| func (r *DockerRuntime) VolumeList(ctx context.Context, prefix string) ([]string, error) { |
There was a problem hiding this comment.
Minor: interface contract doesn't mention the label filter.
Runtime.VolumeList is documented as "returns names of volumes whose name starts with prefix", but this implementation also filters by moat=workspace label. Any caller (or future implementer) relying only on the interface doc would be surprised that volumes without this label are silently excluded even when their names match the prefix.
Since all volumes are created by VolumeCreate which sets the label, this can't bite today. But the interface doc in runtime.go should mention the label filter, or the label should be passed as a parameter. Low priority — just a documentation gap.
…ace; clearer export errors
Bug (review): moat snapshot {list,prune,restore --to} called
snapshot.NewEngine(meta.Workspace, ...), which validates the workspace exists,
even though those operations only read snapshotDir. In volume mode the host tree
was just the staging source and may be deleted/renamed, so these commands failed
with "workspace does not exist" despite the snapshot being self-contained.
Add snapshotEngineWorkspace(): use the real workspace when it exists (preserving
backend auto-detection), else a guaranteed-existing placeholder (os.TempDir).
Used for list, prune, and extract-to-dir restore; in-place restore still requires
the real path (it writes back to the workspace) and volume mode blocks in-place
restore anyway. Adds a companion test.
Minor (review):
- VolumeExport names the base image (volumeExportImage const) in the create-error
so an unpullable image (air-gapped, rate limit, custom registry) is actionable
instead of opaque.
- Document that Runtime.VolumeList filters by the moat=workspace label, not just
the name prefix.
|
Addressed the latest review in |
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 7m 58s —— View job Code Review
SummaryThis is a well-engineered feature. The previous review iterations cleared the major issues: One definite bug before merge: Bug —
|
| if r.SnapEngine != nil && !r.DisablePreRunSnapshot { | ||
| // Create pre-run snapshot. Skipped in volume mode: the host staging tree is | ||
| // not the live workspace, so a pre-run snapshot of it would be meaningless. | ||
| if r.SnapEngine != nil && !r.DisablePreRunSnapshot && !config.IsVolumeMode(r.WorkspaceMode) { |
There was a problem hiding this comment.
The pre-run guard is correct. But r.SnapEngine is still set (non-nil) for volume-mode runs, so the keyboard shortcut in cmd/moat/cli/exec.go's takeSnapshot function (line 701) can fire and call r.SnapEngine.Create(TypeManual, "") on this engine. That engine was initialised with opts.Workspace (the host staging dir, never modified) and no IncludeGit / ForceBackend, so the keyboard snapshot:
- Archives the original host directory — no agent changes included.
- Is
TypeManual, sohasExtractionSnapshotreturnstrue. - That satisfies
CheckDestroyAllowed, lettingmoat destroydelete the Docker volume and lose all the agent's work.
Fix options:
- Guard
takeSnapshotin exec.go:if config.IsVolumeMode(r.WorkspaceMode) { flash("Use 'moat snapshot <run>' instead"); return }. - Or nil out
r.SnapEnginefor volume mode here (but that would also silence the "Snapshots not configured" path in takeSnapshot for the wrong reason).
The cleanest fix is the exec.go guard. Fix this →
… loss) Bug (review): the interactive snapshot keyboard shortcut (takeSnapshot in exec.go) called r.SnapEngine.Create directly. In volume mode r.SnapEngine is non-nil but was initialised with the read-only host staging dir, so a keyboard snapshot archived the original host tree (no agent changes) yet still created a TypeManual snapshot — which satisfies hasExtractionSnapshot and would let `moat destroy` delete the volume and lose all the agent's work. Guard takeSnapshot against volume mode and direct the user to `moat snapshot <run>` (which exports the volume). Adds a companion test (volume -> no snapshot, bind -> snapshot still taken). Minor (review): write snapshots.json with 0600 to match the archive files it sits beside; the metadata itself is non-sensitive but the consistency is nicer in a restricted snapshot dir.
|
Fixed in |
|
Claude finished @dpup's task in 6m 24s —— View job Code Review
SummaryThis PR is ready to merge. After four rounds of review, every flagged issue has been addressed. The implementation is correct, well-tested, and the coverage of companion cases is thorough. No new bugs found.
|
What
Adds an opt-in
workspace.mode: volumethat copies the host working tree into an ephemeral per-run Docker named volume mounted at/workspace, instead of bind-mounting the host tree. Motivation: protect the host tree from agent writes, and get native-speed I/O on macOS (where bind mounts are slow). Changes are recovered to the host via snapshots.Docker-only; Apple containers and git worktrees/submodules are rejected with a clear error (use
workspace.mode: bindfor those).How it works
/mnt/host-workspace) and a fresh per-run named volume (moat-ws-<run-id>) at/workspace.moat-init.sh(forced to run as root) copies staging → volume honoringexclude:patterns, then chowns/workspaceto the run user before dropping privileges via gosu.moat snapshot <run>materializes the volume to a host temp dir via a short-lived sidecartarcontainer, then archives it with.gitincluded. Recover withmoat snapshot restore <run> --to <dir>.moat destroy/moat cleanrefuse to delete a volume run that has no extraction snapshot unless--force(the volume is the only copy of the agent's work).moat-ws-*volumes whose run directory is gone.Built on top of #402's named-volume primitive (
MountConfig.Volume); this PR adds the workspace-copy layer, snapshot extraction, the data-loss guards, and the GC.Review
Self-reviewed with
/ce-code-review(multi-persona) before pushing. Findings were fixed across three follow-up commits, each verified by unit tests + the Docker e2e lifecycle:VolumeExportnow captures the source-tar exit status (a POSIX pipeline previously masked a partial-but-valid source archive → silently truncated snapshot). Orphan GC lists volumes before snapshotting live run dirs (closes a TOCTOU that could reclaim a brand-new run's volume).0:0entrypoint is now treated as a root entrypoint in both the chown-env and ownership-helper paths, so combining volume mode with a configtype: volumevolume chowns it to the run user (not root).hasExtractionSnapshotreads the snapshot store directly so the destroy/clean guard works even after the host workspace is gone;moat cleanexits non-zero when volume runs are skipped (machine-readable signal).VolumeExportis time-bounded.Testing
make test-unit(race) green across config/container/run/daemon/snapshot/cli, plus new companion tests (chown routing,ListSnapshots,hasExtractionSnapshot, GC live-set,IsVolumeMode).internal/e2e/volume_workspace_test.go— Docker-gated full lifecycle (create → agent commits in volume → snapshot → restore → destroy guard) passes.make lint0 issues.Docs
docs/content/guides/15-volume-workspaces.mdreference/01-cli.md(--workspace-mode),reference/02-moat-yaml.md(workspace.mode)docs/plans/2026-06-23-volume-mode-workspaces-{design,plan}.md