Skip to content

feat(automount): boot-time automount for external development SSD#44

Merged
smartwatermelon merged 20 commits into
mainfrom
claude/feat-external-storage-automount-spec-20260424
Apr 25, 2026
Merged

feat(automount): boot-time automount for external development SSD#44
smartwatermelon merged 20 commits into
mainfrom
claude/feat-external-storage-automount-spec-20260424

Conversation

@smartwatermelon
Copy link
Copy Markdown
Owner

Summary

Adds setup-external-automount.sh and 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 from config.confEXTERNAL_STORAGE_UUID.

Design evolution (see docs/specs/2026-04-24-external-storage-automount-design.md for full history)

  • Original design: complex — IOKit LaunchEvents matching on AppleAPFSVolume + UUID as a secondary trigger alongside RunAtLoad, loud-fail via /etc/profile banner, helper script, /var/run failure flag.
  • Post-Phase-4 amendment: dropped LaunchEvents (launchd spuriously fires on every APFS event; noise not worth the benefit). RunAtLoad sufficient.
  • Post-deploy simplification (committed here): dropped the helper script, /etc/profile banner, and /var/run flag. Plist now calls /bin/sh -c directly with an inline 6-attempt diskutil mount <UUID> loop. KeepAlive { SuccessfulExit: false } + ThrottleInterval=3600 handle the disk-missing case. Rationale: if the SSD doesn't mount, every ~/Developer symlink 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 sequence
  • config/config.conf.template — adds EXTERNAL_STORAGE_UUID
  • docs/specs/2026-04-24-external-storage-automount-design.md — design doc
  • docs/plans/2026-04-24-external-storage-automount-implementation.md — implementation plan (references + test-phase structure)

Test plan

  • Shellcheck clean on setup-external-automount.sh and run-app-setup.sh
  • plutil -lint passes on rendered plist (scripted check before install)
  • Pre-commit reviewers PASS on all commits
  • Pre-push Semgrep PASS
  • UUID validated with regex before substitution (defense-in-depth: guards against shell-metachar injection from a corrupt APFS plist)
  • --uninstall requires both BEGIN and END markers before editing /etc/profile, and backs up + size-checks before overwriting (BSD sed footgun mitigation)
  • Live reboot test on MIMOLETTE (first boot post-install): daemon fires at RunAtLoad, runs=1, last exit code=0, state=not running, /Volumes/extra-vieille MOUNTED before GUI login (verified via pre-loginwindow ssh)
  • Second reboot (post-Headroom merge): same result — 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-nightly is filed at smartwatermelon/ralph-burndown#109 (out of scope for this repo).

🤖 Generated with Claude Code

Claude Code Bot added 19 commits April 24, 2026 20:12
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.
@claude

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>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 25, 2026

No blocking issues found.

The new setup-external-automount.sh script is well-guarded: UUID input is validated against a strict regex before sed substitution (line 165), verify_on_target is called for both install and uninstall paths (lines 355, 357), the graceful-skip on empty EXTERNAL_STORAGE_VOLUME is correct (line 349), and error paths roll back the installed plist before exiting (lines 231-236, 257-261). The tee-then-sudo install pattern for /etc/profile cleanup is a known-safe idiom for this use case.

Minor observation (non-blocking): EXTERNAL_STORAGE_UUID is added to config.conf.template but is never read by setup-external-automount.sh, which always auto-discovers the UUID via diskutil. The config var is dead code, but it causes no behavioral problem.

VERDICT: PASS

@smartwatermelon smartwatermelon merged commit 564a782 into main Apr 25, 2026
2 checks passed
@smartwatermelon smartwatermelon deleted the claude/feat-external-storage-automount-spec-20260424 branch April 25, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant