feat(mounts): host, volume and NFS mounts for workspaces (closes #85) #88

Merged
lz merged 26 commits from feat/mounts into main 2026-07-22 19:15:32 +02:00
Owner

Closes #85. Lets an operator mount a host path, a named Docker volume, or a remote NFS export into a workspace — declared per repo, editable per workspace at creation, with a Test mount button that probes a real mount before you spend a spawn on it.

The concrete driver: myuzik is developed in a workspace on this host while its media library lives on the ds9.lan NAS. Verified end-to-end against that export.

Design

Mounts happen at the Docker daemon, not inside the container, so the worker stays unprivileged — no SYS_ADMIN, no /dev/fuse, no mount tooling in the image. Three kinds, differing only in what survives removal:

kind shape on workspace removal
bind host path nothing to reap
volume named volume survives — that's the point
nfs anonymous volume, local driver + driver_opts reaped with the container

Per-repo defaults live in repo_mounts (keyed on repo_full_name like repo_env_vars, so they survive removeWorker). The set actually applied is frozen onto workers.mounts_json — mounts can't change without re-creating the container, and both UIs say so rather than implying a live apply.

Design spec and plan are committed under docs/superpowers/specs|plans/2026-07-22-worker-mounts*. Durable facts are recorded as AGENTS.md #22.

What the probing changed

The issue proposed o: "addr=…,ro,nfsvers=4". That would never have mounted. Docker's local driver calls mount(2) directly and never execs mount.nfs, so nothing negotiates a version or translates option names — nfsvers is a mount.nfs name; the kernel's is vers. Measured against the real NAS:

options result
type=nfs4 / vers=4 / nfsvers=4 protocol not supported
addr=…,ro (bare) permission denied / connection refused
addr=…,ro,vers=3,proto=tcp,nolock mounted

Two more findings: the NFS client is the Docker host (the source address NATs), so export rules must allow the host, not the worker subnet. And a refused mount fails fast at container.start(), while an unreachable one retries indefinitely — which is why the probe arms its timeout before start().

Six attempts to find that string is the entire justification for the Test button.

Review

Ran /simplify (4 agents) and a 5-agent PR review. Notable fixes:

  • A read-write bind at /root/.claude destroyed host data. /root was exact-match reserved but not prefix-reserved, and the entrypoint runs rm -rf /root/.clauderm empties the bind's host contents before failing on the mountpoint. Reproduced on the daemon; /root is now prefix-reserved.
  • //workspace bypassed every reserved-path check. Docker path.Cleans the destination, so it mounted at /workspace while matching neither the exact set nor the prefix guard. Targets are canonicalised now (.. preserved, not resolved).
  • A failed config load became "create with no mounts." [] is a deliberate instruction the server honours; a failed load, a mid-load Create, or a repo switch could all produce it — the last applying repo A's mounts to repo B. Now MountSpec[] | null, and the spawn body omits the field when null so the server falls back to defaults.
  • Adding a 4th kind produced zero type errors. buildMountSpecs and assertValidMount were if-chains ending in the NFS case, so CIFS would have been emitted as an NFS volume with an empty device. Both are now switch + never.
  • capabilities.test.ts asserted from an empty HostConfig, which passes equally for the clobbering form its own comment warned against — a dind workspace would have lost every operator mount while the card still showed them.
  • Test gave a green tick for a typo'd volume — Docker auto-creates it, so myuzik-cahce mounted fine and listed nothing. It also created the typo'd volume, so a retry would find it "existing" and report clean. Now warns without probing.

Testing

  • 105 files / 997 tests, tsc clean, svelte-check 0 errors, lint clean
  • Guards verified by reintroducing the bug and watching the right test go red: the mounts_json clobber, the CIFS exhaustiveness trap, the capabilities clobber, the demuxLogs removal, client/server type drift
  • Browser-verified twice against a real instance: settings card, creation modal, inline editing, reserved-path rejection, delete-while-editing, Create gating, and Test mount returning live files off ds9
  • Named-volume persistence proven directly: write a file, docker rm -v, re-create, read it back

Not done

Spawning a real workspace end-to-end with a mount needs the live Nexus rather than a throwaway instance. The mount path itself is proven — testMount uses the same buildMountSpecs output provisioning does.

Deferred with reasons in-thread: a MountTestResult union (ergonomics only), an EnvVarsEditor extraction (pre-existing code, real regression risk), and a per-kind descriptor table (CIFS's real cost is a sealed credential column, which no such table would save).

Closes #85. Lets an operator mount a host path, a named Docker volume, or a remote NFS export into a workspace — declared per repo, editable per workspace at creation, with a **Test mount** button that probes a real mount before you spend a spawn on it. The concrete driver: **myuzik** is developed in a workspace on this host while its media library lives on the `ds9.lan` NAS. Verified end-to-end against that export. ## Design Mounts happen at the **Docker daemon**, not inside the container, so the worker stays unprivileged — no `SYS_ADMIN`, no `/dev/fuse`, no mount tooling in the image. Three kinds, differing only in what survives removal: | kind | shape | on workspace removal | |---|---|---| | `bind` | host path | nothing to reap | | `volume` | **named** volume | **survives** — that's the point | | `nfs` | **anonymous** volume, `local` driver + `driver_opts` | reaped with the container | Per-repo defaults live in `repo_mounts` (keyed on `repo_full_name` like `repo_env_vars`, so they survive `removeWorker`). The set actually applied is frozen onto `workers.mounts_json` — mounts can't change without re-creating the container, and both UIs say so rather than implying a live apply. Design spec and plan are committed under `docs/superpowers/specs|plans/2026-07-22-worker-mounts*`. Durable facts are recorded as **AGENTS.md #22**. ## What the probing changed The issue proposed `o: "addr=…,ro,nfsvers=4"`. **That would never have mounted.** Docker's `local` driver calls `mount(2)` directly and never execs `mount.nfs`, so nothing negotiates a version or translates option names — `nfsvers` is a `mount.nfs` name; the kernel's is `vers`. Measured against the real NAS: | options | result | |---|---| | `type=nfs4` / `vers=4` / `nfsvers=4` | `protocol not supported` | | `addr=…,ro` (bare) | `permission denied` / `connection refused` | | **`addr=…,ro,vers=3,proto=tcp,nolock`** | **mounted** | Two more findings: the NFS client is the **Docker host** (the source address NATs), so export rules must allow the host, not the worker subnet. And a *refused* mount fails fast at `container.start()`, while an *unreachable* one retries indefinitely — which is why the probe arms its timeout **before** `start()`. Six attempts to find that string is the entire justification for the Test button. ## Review Ran `/simplify` (4 agents) and a 5-agent PR review. Notable fixes: - **A read-write bind at `/root/.claude` destroyed host data.** `/root` was exact-match reserved but not prefix-reserved, and the entrypoint runs `rm -rf /root/.claude` — `rm` empties the bind's *host* contents before failing on the mountpoint. Reproduced on the daemon; `/root` is now prefix-reserved. - **`//workspace` bypassed every reserved-path check.** Docker `path.Clean`s the destination, so it mounted at `/workspace` while matching neither the exact set nor the prefix guard. Targets are canonicalised now (`..` preserved, not resolved). - **A failed config load became "create with no mounts."** `[]` is a deliberate instruction the server honours; a failed load, a mid-load Create, or a repo switch could all produce it — the last applying repo A's mounts to repo B. Now `MountSpec[] | null`, and the spawn body omits the field when null so the server falls back to defaults. - **Adding a 4th kind produced zero type errors.** `buildMountSpecs` and `assertValidMount` were `if`-chains ending in the NFS case, so CIFS would have been emitted as an NFS volume with an empty device. Both are now `switch` + `never`. - **`capabilities.test.ts` asserted from an empty HostConfig**, which passes equally for the clobbering form its own comment warned against — a dind workspace would have lost every operator mount while the card still showed them. - **Test gave a green tick for a typo'd volume** — Docker auto-creates it, so `myuzik-cahce` mounted fine and listed nothing. It also *created* the typo'd volume, so a retry would find it "existing" and report clean. Now warns without probing. ## Testing - **105 files / 997 tests**, `tsc` clean, `svelte-check` 0 errors, lint clean - Guards verified by **reintroducing the bug** and watching the right test go red: the `mounts_json` clobber, the CIFS exhaustiveness trap, the capabilities clobber, the `demuxLogs` removal, client/server type drift - Browser-verified twice against a real instance: settings card, creation modal, inline editing, reserved-path rejection, delete-while-editing, Create gating, and **Test mount returning live files off `ds9`** - Named-volume persistence proven directly: write a file, `docker rm -v`, re-create, read it back ## Not done Spawning a real workspace end-to-end with a mount needs the live Nexus rather than a throwaway instance. The mount path itself is proven — `testMount` uses the same `buildMountSpecs` output provisioning does. Deferred with reasons in-thread: a `MountTestResult` union (ergonomics only), an `EnvVarsEditor` extraction (pre-existing code, real regression risk), and a per-kind descriptor table (CIFS's real cost is a sealed credential column, which no such table would save).
lz added 26 commits 2026-07-22 19:13:27 +02:00
Three mount kinds in one HostConfig.Mounts array — bind (host path), named
volume (persists across workspace re-create), and NFS (anonymous volume with
local-driver driver_opts, mounted by the host kernel so the worker stays
unprivileged). Per-repo defaults in repo_mounts, per-workspace snapshot in
workers.mounts_json, edited inline from the creation modal and the Environment
settings tab.

Records the probe results that shaped it: vers=3,proto=tcp is mandatory (the
local driver calls mount(2) directly, so the issue's nfsvers=4 would never
have mounted), the NFS client is the Docker host after NAT, and remove({v:true})
reaps the anonymous volume while leaving a named one alone.
15 tasks, TDD throughout: migration, mount vocabulary, validation, the pure
buildMountSpecs translator, CRUD service, test-mount probe, spawn wiring, API
routes, client, the shared MountsEditor with inline row editing, both UI
surfaces, the card readout, docs, and a browser + live-NFS verification pass.
Exact matching let /workspace/.nexus/worktrees and /session/dot-claude through.
Those hold every session's worktree and the fleet's shared OAuth credentials, so
a mount there shadows them as destructively as one on the root — silently, since
the container still starts.

Prefix-matching applies only to the roots Nexus owns the subtree of; /etc and
/usr stay exact-match so /etc/myapp remains mountable. The check matches on a
trailing separator, so /workspace-data is unaffected.
test-mount.ts correctly reused workers/service.ts's demuxLogs rather than
duplicating it, but Task 7 makes workers/service.ts import mounts/*, which would
put a cycle between the two directories — and would pull workers/service's lazy
singletons import into the mounts module graph for twenty lines of helper.

Same function, now in lib/docker-logs.ts, imported by both. No behaviour change.
Task 7 correctly restated mounts_json with the real value rather than null, but
nothing in the suite exercised spawnWorker at all — so reintroducing the clobber
would have stayed green while every created workspace reported zero mounts
through the API despite having them.

Verified by reintroducing the bug: 'returns the mounts it just snapshotted' goes
red, and passes again once reverted. Also covers the explicit-empty-set case,
which must not fall back to the repo defaults.
MountSpec, MOUNT_KINDS and the default NFS option string are declared on both
sides of the wire. The two types are structurally unrelated, so a field added to
one side only makes the API silently lossy with no type error anywhere.

Same guard bind-host.test.ts establishes for enums, per AGENTS.md. Verified by
adding a client-only field: the field-set test goes red, and passes on revert.
capabilities.ts claimed to BE the security boundary for HostConfig. After #85 it
is no longer the only writer — operator mounts contribute entries too — so the
comment is scoped to image-derived deltas rather than left confidently wrong.

AGENTS.md fact #22 records what cost time to discover: vers=3,proto=tcp is
mandatory and nfsvers does nothing (the local driver calls mount(2) directly),
the NFS client is the Docker host after NAT, named volumes survive remove -v
while anonymous ones don't, and undefined-vs-empty mounts must stay distinct.
Browser verification caught what the DOM snapshot could not: the result panel
rendered once at the bottom of the list, so in the creation modal it landed
below the fold and clicking Test appeared to do nothing at all. With several
rows it also appeared nowhere near the button that produced it.

The result now renders directly beneath its row (or inside the open editor for
the draft), tracked by testFor. Also wraps the source line at word boundaries
rather than mid-token.
- specToRow(): the 8-field spec→row mapping was written twice in service.ts
- replaceRepoMounts now calls normalizeAndValidateSet instead of re-inlining it
- drop buildWorkerHostMounts, a one-line wrapper whose docstring claimed it
  existed for testability while buildMountSpecs was already exported and tested;
  its duplicate test block goes with it
- mountSourceLabel() shared from api/types instead of a per-component copy
- mounts/fixtures.ts: bind()/vol()/nfs() were byte-identical in five test files
- .btn.xs moved to app.css beside .btn.sm — it had been copied into three
  components with the values already drifting between them
- MountsEditor: one tagged test state instead of three signals that had to move
  in lockstep and were reset apart
- NewWorkerModal: drop a MountDTO→MountSpec remap that TypeScript gives free

Behaviour-preserving. Re-verified in a browser: settings card, modal drawer, and
the Test flow all render as before.
From the type-design review. Three real gaps, all on the path a future CIFS kind
would take:

- buildMountSpecs and assertValidMount were if-chains ending in the NFS case, so
  a 4th kind fell through and was emitted as an NFS volume with an empty device
  (and rejected with 'needs a server address'). Adding 'cifs' to MOUNT_KINDS
  produced ZERO type errors. Both are now switches with a never default —
  verified: the same edit now errors in exactly those two files.
- resolveSpawnMounts trusted saved rows because they were validated on write.
  That stops being true the moment a rule tightens, so it re-validates.
- rowToDTO coerced an unrecognized kind to 'bind', which reaches Docker as
  {Type:'bind', Source:''} — a silently wrong mount. It now drops the row with a
  warning; a missing mount is debuggable, a bind of nothing is not.

Also corrects MountSpec's doc comment: the flat shape is justified by the DB row
and the editor draft, not by JSON — zod's discriminatedUnion would be better at
that boundary, and the old wording implied otherwise.
From the silent-failure review. The client half of this feature had the real
defects; the server plumbing was found sound.

Critical — NewWorkerModal treated an unreadable repo config as an empty one.
`[]` is an explicit instruction (resolveSpawnMounts honours it and does NOT
fall back to defaults), and `mounts` reached `[]` three ways that had nothing to
do with the operator emptying it: a failed load, Create clicked mid-load, and a
repo switch — the last applying repo A's mounts to repo B's workspace, since the
old code never cleared them before fetching. `mounts` is now `MountSpec[] | null`,
the spawn body OMITS the field when null (so the server applies saved defaults —
the safe direction), Create is gated on the load completing, and a sequence guard
stops a stale response winning.

Important:
- A failed mount delete set rowError, which only rendered inside the editor
  snippet — and Del is only clickable when no editor is open. The operator
  confirmed a delete, it failed, and nothing appeared.
- testMount reported a green tick for a named volume that did not exist: docker
  auto-creates it, so a typo'd name mounted fine and listed nothing — the exact
  mistake the button exists to catch. It also CREATED the typo'd volume, which
  survives remove({v:true}). Now pre-checks and returns a warning.
- Test is no longer offered for an image that isn't local yet; 'No such image'
  was rendering as though the operator's NFS options were wrong.

Also: parseMountsJson now shape-checks elements and logs what it discarded, and
EnvironmentPanel's cosmetic count-refresh can no longer report a persisted save
as 'save failed'.
From the code, test and comment reviews.

CRITICAL — /root was exact-match reserved but not prefix-reserved, so a mount at
/root/.claude passed validation. The entrypoint runs 'rm -rf /root/.claude', and
rm deletes a bind mount's CONTENTS before failing on the mountpoint: measured on
the daemon, the host directory is emptied irreversibly and provisioning then
dies with 'Resource busy', which names nothing. /root is now prefix-reserved.

- Targets are canonicalised before every check. Docker path.Cleans the
  destination, so //workspace and /./workspace mounted at /workspace while
  matching neither the exact set nor the prefix guard — every reserved-path rule
  could be walked past. '..' is still preserved, not resolved.
- testMount arms its timeout BEFORE container.start(). start() is where the
  kernel mounts, and a hard NFS mount to an unreachable address retries forever
  — racing only wait() left the probe pinned on the exact mistake it exists to
  catch, leaking the container because finally was never reached.
- Deleting a row while another was open for editing corrupted the set: 'editing'
  is an index into an array that just shifted, so the next Save wrote the open
  draft over a different mount and dropped one silently.
- upsertRepoMount now rejects an id that isn't one of the repo's rows. The
  foreign-id case fell into the insert branch reusing that id, surfacing a raw
  'UNIQUE constraint failed' as a 400. Found by the test written for it.
- RESERVED_VOLUME_NAMES includes the configured CLAUDE_SESSION_VOLUME, not just
  the literal — the only way found to get the fleet's OAuth volume somewhere it
  doesn't belong.
- mountSourceLabel is exhaustive; it was the last non-exhaustive kind switch.

Tests: capabilities.test.ts now applies dind to a HostConfig that already holds
an operator mount — the old assertion started from an empty one and passed just
as well for the clobbering form it warned against (verified: injecting that form
now fails). Plus demuxLogs framing (the probe is always framed and no test fed a
framed buffer), NFS_ADDR_RE option-injection, and the foreign-id guard. Dropped
one parity test that could never fail.

Comments: corrected an upsert docstring describing a UI that doesn't exist, a
rowToDTO rationale made stale by its own commit, and 'both writers append' —
provisionWorker seeds, capabilities spreads. AGENTS.md #22 gains the
exhaustiveness, re-validation, dual-declaration and seed/spread contracts, and
no longer claims an unreachable server errors at start().
fix(mounts): don't create the volume the test is warning about
All checks were successful
ci / nexus (pull_request) Successful in 5m31s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 8s
ci / images (pull_request) Successful in 7m9s
5fd5a60948
Browser pass caught the residual: warning about a non-existent named volume was
not enough, because the probe still MOUNTED it — which auto-creates it, and a
named volume survives remove({v:true}). Confirmed after the run: a stray
'myuzik-cahce' was left on the host. A second Test would then find it existing
and report a clean tick, so the button lied on retry.

Now returns the warning without probing. There is nothing to learn from mounting
an empty volume Docker would have created for us anyway, and the test asserts
createContainer is never called.
lz merged commit aa8c92803e into main 2026-07-22 19:15:32 +02:00
lz deleted branch feat/mounts 2026-07-22 19:15:32 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!88
No description provided.