#308 macOS launchd autoupdate (club.swamp.autoupdate) silently fails — binary stays stale
Opened by bixu · 5/10/2026· Shipped 5/10/2026
Summary
On macOS, the club.swamp.autoupdate LaunchAgent installed by swamp runs on schedule (exit 0, daily) but never actually updates /usr/local/bin/swamp. A manual sudo swamp update immediately found and installed a newer build, proving the autoupdate runs were no-ops despite an upgrade being available.
Steps to Reproduce
- Install swamp on macOS such that the binary lands at
/usr/local/bin/swampowned byroot:wheel(the default for a system-wide install). - Let swamp install its LaunchAgent at
~/Library/LaunchAgents/club.swamp.autoupdate.plist. The plist runs as the logged-in user and invokes:
with/usr/local/bin/swamp update --backgroundStandardOutPathandStandardErrorPathboth set to/dev/null. - Wait at least one daily interval (or longer — multiple runs).
- Run
sudo swamp updateinteractively.
Observed: the manual sudo run reports an update is available and installs it:
swamp updated successfully!
"20260508.001043.0-sha.3d787176" → "20260509.235714.0-sha.7ace6b02"
SHA-256 integrity check passedExpected: the LaunchAgent should have already applied this update during one of its prior daily runs.
Evidence the LaunchAgent is firing but not updating
launchctl print gui/501/club.swamp.autoupdate:
runs = 3
last exit code = 0
state = not running
run interval = 86400 secondsUnified log (log show --predicate 'eventMessage CONTAINS "swamp.autoupdate"' --last 7d) shows the service going inactive at ~24h intervals matching the install time:
2026-05-09 11:22:47 service inactive: club.swamp.autoupdate
2026-05-10 11:22:48 service inactive: club.swamp.autoupdate/usr/local/bin/swamp mtime: still May 8 11:22 (the original install time) right up until the manual sudo swamp update.
Likely Root Cause
The LaunchAgent runs as the unprivileged user, but /usr/local/bin/swamp is owned by root:wheel (mode 0755):
-rwxr-xr-x 1 root wheel /usr/local/bin/swampThe user can read/exec the binary but cannot replace it. swamp update --background therefore can't write the new binary and silently exits 0 (or fails in a way invisible to the user, since stdout/stderr are wired to /dev/null in the plist).
Suggested Fixes
Pick one or more — they're complementary:
- Detect and surface the permission mismatch. When
swamp update(especially--background) cannot write the target binary path, it should exit non-zero with a clear diagnostic, and the LaunchAgent should not mask that exit code. - Don't
/dev/nullthe logs. The shipped plist should write to a known log path (e.g.~/Library/Logs/swamp/autoupdate.log) so users can see why updates aren't landing. - Install the autoupdate as a privileged LaunchDaemon when the binary is owned by root, or document clearly that system-wide installs require either chowning the binary to the user or installing a daemon variant.
- Atomic-replace via a writable staging dir +
sudo-less mechanism (e.g. a small privileged helper installed once at install time) — closer to how other auto-updaters handle this.
Environment
- macOS (Darwin 25.4.0)
- swamp
20260508.001043.0-sha.3d787176→ after manual update20260509.235714.0-sha.7ace6b02 - Binary path:
/usr/local/bin/swamp(ownerroot:wheel, mode0755) - LaunchAgent path:
~/Library/LaunchAgents/club.swamp.autoupdate.plist - Plist contents (relevant bits):
ProgramArguments = ["/usr/local/bin/swamp", "update", "--background"],StartInterval = 86400,RunAtLoad = true, stdout/stderr →/dev/null
Shipped
Click a lifecycle step above to view its details.
bixu commented 5/10/2026, 10:17:53 AM
Apple-documented approach for this class of problem
Researched the canonical macOS pattern for "scheduled background update of a root:wheel binary". Summarising here so a fix can pick the right primitive rather than reinventing one.
1. Use a LaunchDaemon, not a LaunchAgent
Per Apple's Daemons and Services Programming Guide (the doc that absorbed Technical Note TN2083), LaunchAgents run per-user and unprivileged; LaunchDaemons run system-wide as root. To replace files owned by root:wheel, the scheduler must be a daemon.
- Plist location:
/Library/LaunchDaemons/ - Ownership:
root:wheel, mode0644 launchdruns it as root unlessUserNameis explicitly set otherwise- Reference: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html
2. Two documented ways to install a daemon
a) Signed .pkg installer. The installer drops both the binary in /usr/local/bin/ and the daemon plist in /Library/LaunchDaemons/ during the single admin-authenticated install step the user already consents to. From then on, launchd runs swamp update --background as root on StartInterval (or StartCalendarInterval) and can atomically replace the binary. This is how Homebrew casks, Docker Desktop, and most CLI vendors ship their updaters.
b) SMAppService.daemon(plistName:) (macOS 13+). Modern Service Management API. Daemon plist is embedded in an app bundle at Contents/Library/LaunchDaemons/<label>.plist, registered at runtime, gated by code-signing validation, and manageable from System Settings → Login Items & Extensions.
3. For on-demand privilege escalation from a user-context process (the "Sparkle pattern")
Apple documents the privileged helper tool: a small code-signed executable installed once, registered with SMJobBless (legacy, deprecated macOS 13+) or SMAppService.daemon (modern). The unprivileged process talks to the helper over XPC; the helper does the file replacement as root.
Required Info.plist keys:
SMPrivilegedExecutablesin the helperSMAuthorizedClientsin the calling app- Both bound by code-signing requirements
4. Authorization Services (lower-level primitive)
AuthorizationCreate / AuthorizationCopyRights are used by both #2 and #3 under the hood to obtain a one-shot admin right and hand it to a privileged child. Mostly relevant if building #3 from scratch.
Ranked fit for swamp's CLI
- Best —
LaunchDaemonshipped by the install mechanism. Whatever bootstrap puts swamp at/usr/local/bin/swamp(whether a.pkg, a curl-pipe-sh installer withsudo, etc.) should also drop/Library/LaunchDaemons/club.swamp.autoupdate.plist. Single admin prompt at install, no per-update prompts, root-owned binary replacement just works. - Acceptable — keep the LaunchAgent + privileged helper. Retain the per-user agent for scheduling, but factor the actual replacement step into a
SMJobBless/SMAppService.daemon-installed helper that runs as root. - Don't — the current setup. A user-domain LaunchAgent running
swamp update --backgroundagainst aroot:wheelbinary cannot succeed; it just exits 0 because stdout/stderr are wired to/dev/null.
Suggested next step
If a .pkg-style installer is acceptable in swamp's distribution model, option 1 is the lowest-friction fix and matches what most macOS CLI vendors ship.
bixu commented 5/12/2026, 11:00:43 AM
So the fix here is to change the binary ownership, or throw if the binary is not owned by the user? Doesn't that open up an attack surface where some other software with user perms can swap out the binary or mutate it? Maybe I'm misreading the diff.
Sign in to post a ripple.