ProtectSystem=strict in claw-store-serve.service only listed
/var/lib/claw-store as writable. The serve process shells out to
`claw-store activate/deactivate` which also needs to write to:
/hot/targets — create/remove hot target dirs
/slab/projects — write/remove .cargo/config.toml
Subprocess inherits the service's mount namespace, so both paths were
silently read-only, causing activate/deactivate from the dashboard to
return {"ok":false,"error":"Read-only file system (os error 30)"} while
the same commands worked fine from a login shell.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
The v0.2.0 unit set ProtectHome=true to lock down /home from a
hypothetical RCE in axum. Side effect: ssh in peer_reachable
(serve.rs:145) failed with "Host key verification failed: Permission
denied" because ~/.ssh/known_hosts was unreachable. Adding
BindReadOnlyPaths=/home/osobh/.ssh didn't help — systemd applies
ProtectHome before the bind mounts run, so /home is already an
inaccessible barrier when the bind lands. Result: both dashboards
showed peer_reachable=false even though LAN ping + SSH worked fine
from a shell.
Two options to keep some sandboxing:
1. ProtectHome=tmpfs + BindReadOnlyPaths=/home/osobh/.ssh — bind
into an empty tmpfs view of /home.
2. Drop ProtectHome entirely — keep ProtectSystem=strict +
ReadWritePaths=/var/lib/claw-store + NoNewPrivileges + PrivateTmp.
Going with (2) for now. The threat model is local-host RCE in a
read-mostly axum service the dashboard pokes; ProtectSystem alone
prevents writing anywhere outside /var/lib/claw-store. Re-introduce
(1) when we have a clean justification.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
The two biggest pain points coming out of the architecture review:
1. The manifest at /var/lib/claw-store/projects.toml was the only
piece of writable state but had no locking, no atomic writes,
and three concurrent writers (daemon poll tick, every CLI verb,
and the dashboard shelling out via /api/activate). Two writers
interleaving silently dropped one of them; a crash mid-write
left a corrupt half-written TOML that the next reader parsed
as an empty manifest.
2. Reboot survival: the dashboard had no systemd unit and was a
stray hand-launched process. Architect lost its dashboard on
todays reboot.
This commit lands:
- Manifest::update(path, FnOnce(&mut Manifest)) — locked-atomic
load-mutate-save in one transaction. Uses libc::flock(LOCK_EX) on
a sidecar .lock file (so the data file can be replaced by rename
without invalidating the lock) and tempfile + persist for the
rename. Concurrent writers serialise; readers see the previous
state or the new state, never a torn write. Manifest::load uses
LOCK_SH so it never races a mid-rename.
- Project.pinned: bool with #[serde(default)] so legacy manifests
parse cleanly. hot::gc_stale_targets and hot::gc_by_space both
skip pinned projects, with a WARN log when every remaining
project is pinned but were still over budget — operator intent
beats space pressure.
- claw-store pin <project> / unpin <project> CLI verbs.
status command surfaces pin marker (📌).
activate preserves an existing rows pinned flag so re-activating
doesnt silently unpin.
- claw-store-serve.service systemd unit. Type=simple, Restart=
on-failure, RestartSec=15, ProtectSystem=strict + ReadWritePaths
=/var/lib/claw-store, ProtectHome, NoNewPrivileges, PrivateTmp.
- daemon poll tick reloads the manifest from disk at the start of
each cycle (so CLI activations between ticks are visible) and
routes its GC write through Manifest::update (so it cant race a
concurrent CLI pin).
- libc + tempfile move from dev-deps into runtime deps.
- empty-manifest fallthrough on load (treat "" as default) so a
half-written tempfile crashed pre-rename doesnt hard-fail the
daemon next boot.
- 25 tests passing incl. new ones: legacy-toml-parses, update-
serializes-two-sequential-writers, pinned-survives-stale-gc,
pinned-survives-space-gc-even-when-lru.
Version bumped 0.1.0 → 0.2.0.
Co-Authored-By: Claude Opus 4.7 <[email protected]>