feat(automount): boot-time automount for external development SSD#44
Conversation
Captures the design for a LaunchDaemon + helper script that mounts the external Thunderbolt SSD (/Volumes/extra-vieille) at boot, before sshd serves connections, without requiring a macOS GUI login. Solves the SSH-after-reboot problem on FileVault-protected Mac Minis where the dev environment (dotfiles, claude-config, claude-wrapper, project repos) lives on an external drive that only auto-mounts on desktop login. Uses IOKit match events keyed on the APFS volume UUID for instant fire on disk enumeration, RunAtLoad as a belt-and-suspenders fallback, and a three-layer loud-fail stack (syslog + tmpfs flag file + login banner) for visible failure when the disk is absent. Not implemented yet — this is the design document only. Implementation plan to follow.
20 tasks covering: config template update, three template files (plist, helper, banner), setup script in four stages (skeleton → dry-run → install-only → install → uninstall), run-app-setup.sh wiring, spec open-question resolution, and seven test phases mapped 1:1 to the spec's test plan. Every step is bite-sized, includes full code where code is written, and has an acceptance check. Pairs with: docs/specs/2026-04-24-external-storage-automount-design.md
Adds the APFS volume UUID variable consumed by the upcoming setup-external-automount.sh script. Left blank by default; setup-external-automount.sh will auto-discover from the mounted volume at runtime.
Helper script invoked by the LaunchDaemon to mount the APFS volume identified by UUID at boot. Idempotent; loud-fails via syslog + tmpfs flag file on any error path with distinct exit codes (1=UUID missing, 2=mount failed, 3=mount-point missing, 4=config missing).
Two fixes flagged in code review of the initial template: 1. Empty EXTERNAL_STORAGE_UUID or EXTERNAL_STORAGE_VOLUME now calls loud_fail 4 instead of bash :? expansion, so the flag file and logger syslog entry fire correctly. Previously an empty value bypassed both. 2. Added a mkdir-based non-blocking lock at /var/run/mount-external- storage.lock to serialize concurrent RunAtLoad + IOKit-match invocations. A second instance sees the lock, logs, and exits 0 cleanly — preventing spurious failure flags on normal boots.
Widen the description to cover the new empty-required-var case added in 16e7296 (previously only mentioned missing/unreadable).
Declares com.mac-dev-server.automount-external-storage, invoked at
RunAtLoad and on AppleAPFSVolume match events keyed on the APFS
volume UUID. The {{UUID}} placeholder is substituted at install
time by setup-external-automount.sh.
Also updates spec + plan to use AppleAPFSVolume instead of the
generic IOMedia class. Empirical check on MIMOLETTE via
ioreg -c IOMedia -r -l -n extra-vieille shows the APFS volume
registers as class AppleAPFSVolume with the matching UUID
property. Caught by the Task 3 adversarial-reviewer before the
plist template was committed.
Block with BEGIN/END markers appended to /etc/profile by setup-external-automount.sh. Fires for interactive shells only (SSH logins), no-op for non-interactive shells (cron, scripts). Surfaces boot-time mount failures so a human SSH-ing in cannot miss them.
The banner template gets APPENDED to /etc/profile (not executed standalone), so a shebang is wrong. Also the shebang was outside the BEGIN/END markers, meaning it wouldn't get cleaned up on uninstall — stale #!/bin/bash lines would accumulate in /etc/profile on repeated install/uninstall cycles. Replaced with "# shellcheck shell=bash" directive inside the BEGIN/END block so shellcheck picks the right dialect without a shebang, and the directive gets cleanly installed/uninstalled with the rest of the block.
Argument parsing, config loading, hostname guard against running on dev machines, UUID auto-discovery, and shared rendering + validation helpers. Mode dispatch is stubbed; subsequent tasks implement dry-run, install-only, install, and uninstall flows.
Renders all three templates to a tempdir, runs plutil -lint and shellcheck against the rendered output, and prints a plan of the install actions without touching /Library/ or /etc/. Maps to Phase 1 of the test plan.
Renders + validates templates, then installs four target files with correct owner/mode (plist, helper script, automount.conf) and appends a BEGIN/END-tagged banner block to /etc/profile. Idempotent: re-running leaves /etc/profile unchanged on the second pass. Does not invoke launchctl bootstrap. Maps to Phase 2 of the test plan.
Wraps do_install_only, then launchctl bootstraps the daemon and verifies it appears in launchctl list. If the daemon is already loaded (e.g., from earlier testing), boots it out first so the new plist takes effect. If bootstrap fails or the daemon is missing after, removes the plist from /Library/LaunchDaemons/ so a subsequent reboot does not try to load a known-broken daemon. Maps to Phase 3 of the test plan.
Boots out the LaunchDaemon, removes the plist, support dir, and banner block from /etc/profile. Idempotent (safe to re-run). Clears any lingering /var/run flag. Maps to Phase 7 of the test plan.
Runs immediately after storage-setup.sh so the LaunchDaemon is installed once storage symlinks are in place. Both scripts have their own guards on EXTERNAL_STORAGE_VOLUME, so no additional conditionals are needed at the orchestrator level.
Picked /etc/profile append over /etc/bashrc.d/ because /etc/profile already sources /etc/bashrc for bash and fires for all SSH login shells. Block uses BEGIN/END markers for idempotent install/uninstall via sed.
Phase 4 testing on MIMOLETTE showed the IOKit match event on AppleAPFSVolume + UUID re-fires every ~30 seconds for the lifetime of the session when the volume is persistently present. With the idempotent already-mounted early-exit each invocation is cheap, but at ~400 bytes × 2,880 invocations/day the log grows ~400MB/year for zero operational benefit — MIMOLETTE's SSD is in a permanent Thunderbolt dock, hot-plug detection is not needed. Removed the LaunchEvents block. RunAtLoad alone fires the daemon once at boot. Verified: 90s idle = zero new invocations. Spec gains a prominent Design Note at the top explaining the change; the body is preserved for history (and the concurrency analysis is still relevant for the at-boot race between RunAtLoad and diskarbitrationd). Plan gains an analogous amendment note. Hot-plug during uptime now requires manual launchctl kickstart or a reboot.
First deploy on MIMOLETTE exposed a boot-time race: launchd fires
RunAtLoad at extremely-early PIDs (observed PID 349), ~12s before
Thunderbolt disk enumeration completes. The 10-second wait-loop in
the helper script was too short; exit 1 -> loud-fail -> banner on
next login.
Rather than patch the wait-loop, we simplified. The plist now calls
/bin/sh -c directly with a 6-attempt `for` loop around
`diskutil mount UUID`, plus `KeepAlive { SuccessfulExit: false }` and
`ThrottleInterval=3600`. launchd handles retry/backoff natively:
- Happy path: mount succeeds first try, KeepAlive does not respawn
- Boot race: inline loop covers 60s window
- Disk missing: 6 attempts/spawn + 1h throttle = ~144 stderr
lines/day in unified log (trivial)
- Hot-plug mid-uptime still requires manual kickstart or reboot
(unchanged from post-Phase-4 design)
Deleted:
- mount-external-storage.sh.template (115 lines)
- etc-profile-banner.sh.template (11 lines)
Shrunk:
- plist template 42 -> 27 lines
- setup-external-automount.sh 404 -> 307 lines
Goal "Loud on failure" is dropped. Broken ~/Developer symlinks alert
the operator on first command after login; the banner was
belt-and-braces. --uninstall retains defensive cleanup of legacy
artifacts (support dir, profile banner block, /var/run flag).
Spec and plan gain top-of-doc notes explaining the simplification;
existing content preserved as historical record.
Verified on MIMOLETTE: dry-run lints clean, plutil -lint passes,
diskutil mount confirmed idempotent on already-mounted volume
(exit 0) and cleanly errored on unknown UUID (exit 1).
Addresses two reviewer concerns on 6547037:
1. `discover_uuid` now regex-checks the UUID
(^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-{3}[0-9A-Fa-f]{12}$) before
returning. The UUID is interpolated verbatim into the plist's
<string> element which /bin/sh then word-splits; a malformed value
from a corrupt APFS plist could in principle inject shell
metacharacters. Real APFS UUIDs never do, but defense-in-depth is
cheap.
2. The `--uninstall` legacy /etc/profile cleanup previously ran
`sed '\|BEGIN|,\|END|d'` after only checking for the BEGIN marker.
On BSD sed, a missing END marker deletes BEGIN-to-EOF. Now:
- Require both BEGIN and END markers before touching the file;
if BEGIN is present without END, abort with an error.
- Back up /etc/profile to
/etc/profile.automount-uninstall.bak.<ts> before overwriting.
- Refuse to overwrite if the sed output shrank by more than 2KB
(expected delta is ~600 bytes for the banner block).
Both behaviors exercised by `--dry-run` which still runs clean on
MIMOLETTE. No behavior change for the install / install-only paths.
This comment has been minimized.
This comment has been minimized.
claude[bot] blocking finding on PR #44: the `${VAR:?}` expansion on load_config line 84 exited non-zero when EXTERNAL_STORAGE_VOLUME was empty (the default in config.conf.template). Any target that shipped with the default config would get a failed step 2 on every run-app-setup.sh invocation, even though the config comment explicitly invites leaving it blank to skip. Mirror the pattern from storage-setup.sh:157-161: accept the blank value in load_config (normalize via `${VAR:-}`), then skip early in main() with a "skipping — set to enable" log line and exit 0. The skip runs before verify_on_target so operators on non-target machines don't get misleading hostname-mismatch errors for a setup they weren't going to run anyway. Uninstall is exempted — if the config was blanked out after a prior install, operators can still run uninstall to clean up the leftover LaunchDaemon and any legacy /etc/profile banner / Application Support directory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
No blocking issues found. The new Minor observation (non-blocking): VERDICT: PASS |
Summary
Adds
setup-external-automount.shand a minimal LaunchDaemon that mounts the external APFS development volume at boot, before any user session. Target: MIMOLETTE (Apple Silicon Mac mini, headless build server). Volume UUID comes fromconfig.conf→EXTERNAL_STORAGE_UUID.Design evolution (see
docs/specs/2026-04-24-external-storage-automount-design.mdfor full history)LaunchEventsmatching onAppleAPFSVolume+ UUID as a secondary trigger alongsideRunAtLoad, loud-fail via/etc/profilebanner, helper script,/var/runfailure flag.RunAtLoadsufficient./etc/profilebanner, and/var/runflag. Plist now calls/bin/sh -cdirectly with an inline 6-attemptdiskutil mount <UUID>loop.KeepAlive { SuccessfulExit: false }+ThrottleInterval=3600handle the disk-missing case. Rationale: if the SSD doesn't mount, every~/Developersymlink dereference fails visibly — the banner was belt-and-braces.Files
app-setup/setup-external-automount.sh— installer (dry-run / install-only / full install / uninstall modes, with rollback)app-setup/templates/com.mac-dev-server.automount-external-storage.plist.template— minimal plist template (UUID substituted at install)app-setup/run-app-setup.sh— wires the new setup into the boot sequenceconfig/config.conf.template— addsEXTERNAL_STORAGE_UUIDdocs/specs/2026-04-24-external-storage-automount-design.md— design docdocs/plans/2026-04-24-external-storage-automount-implementation.md— implementation plan (references + test-phase structure)Test plan
setup-external-automount.shandrun-app-setup.shplutil -lintpasses on rendered plist (scripted check before install)--uninstallrequires both BEGIN and END markers before editing/etc/profile, and backs up + size-checks before overwriting (BSD sed footgun mitigation)RunAtLoad,runs=1, last exit code=0, state=not running,/Volumes/extra-vieilleMOUNTED before GUI login (verified via pre-loginwindow ssh)runs=1, exit=0, MOUNTED. No regression.Scope note
Discovery during testing that user-level LaunchAgents don't auto-load on headless Macs led to a separate, already-merged PR (#43, Headroom → LaunchDaemon). This branch was rebased onto post-#43 main before push; a comparable issue for
ralph-nightlyis filed atsmartwatermelon/ralph-burndown#109(out of scope for this repo).🤖 Generated with Claude Code