Skip to main content
← Back to list
01Issue
BugShippedSwamp CLI
Assigneesstack72

#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

  1. Install swamp on macOS such that the binary lands at /usr/local/bin/swamp owned by root:wheel (the default for a system-wide install).
  2. Let swamp install its LaunchAgent at ~/Library/LaunchAgents/club.swamp.autoupdate.plist. The plist runs as the logged-in user and invokes:
    /usr/local/bin/swamp update --background
    with StandardOutPath and StandardErrorPath both set to /dev/null.
  3. Wait at least one daily interval (or longer — multiple runs).
  4. Run sudo swamp update interactively.

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 passed

Expected: 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 seconds

Unified 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/swamp

The 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/null the 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 update 20260509.235714.0-sha.7ace6b02
  • Binary path: /usr/local/bin/swamp (owner root:wheel, mode 0755)
  • 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
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 7 MOREREVIEW+ 3 MOREPR_MERGEDSHIPPED

Shipped

5/10/2026, 10:39:28 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack725/10/2026, 8:45:48 PM
Editable. Press Enter to edit.

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.

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:

  • SMPrivilegedExecutables in the helper
  • SMAuthorizedClients in the calling app
  • Both bound by code-signing requirements

Reference: https://developer.apple.com/documentation/servicemanagement/updating_helper_executables_from_earlier_versions_of_macos

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

  1. Best — LaunchDaemon shipped by the install mechanism. Whatever bootstrap puts swamp at /usr/local/bin/swamp (whether a .pkg, a curl-pipe-sh installer with sudo, 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.
  2. 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.
  3. Don't — the current setup. A user-domain LaunchAgent running swamp update --background against a root:wheel binary 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.