proposals/2026-08-08-macos-host-as-a-launchdaemon.md
Rama master · réplica de lectura del repositorio original.
# The macOS host becomes a root LaunchDaemon
## Goal
A Mac running PearTune keeps serving music when nobody is logged in, closing the one platform
gap left by `2026-07-31-desktop-host-as-a-service.md`, which shipped Linux and Windows and
deliberately left macOS as a tray app.
## Tier
T2. It moves the data dir - and a data dir that moves without a migration destroys the library
identity every paired phone knows it by - and it changes who the host runs as. No wire-protocol,
pairing, grant or crypto change. Same tier, and for the same reason, as the Linux/Windows
proposal it follows.
It is worth saying plainly why this is not T3 even though "runs as root" appears in it: the
privilege change is in the LAUNCHER, not in the security model. The firewall, the grant store
and the revoke path are untouched, and the host's authority over who gets in is exactly what it
was. What root buys is a domain that survives logout, and nothing else.
## Background
macOS was scoped out on 2026-07-31 with a measurement, not a shrug. A LaunchAgent in
`~/Library/LaunchAgents` loads into `gui/501`, a domain `loginwindow` builds at login and tears
down at logout, so `KeepAlive` cannot save it - proven with a heartbeat agent across one real
logout/login (killed at 21:58:04, a DIFFERENT pid at 21:58:52). macOS also refuses to bootstrap
an arbitrary job into `user/501` (error 5), so there is no middle ground. It is the GUI session
or a root LaunchDaemon.
Tim's call at the time was "macOS stays a tray app". This proposal revisits that, because the
single biggest reason it was unattractive has since been disproven.
## What was measured, not assumed
**The hardened-runtime scar is gone, and that is what unblocks this.** The 2026-07-31 proposal
listed "runs the LAN code with no user session - the exact area where PearTune already has a
scar, since hardened runtime silently blocks HyperDHT's raw UDP" as a reason to stay away. That
inherited claim was **disproven end to end on 2026-08-01**: hardened and unhardened hosts had
identical socket profiles (18 UDP sockets, 10 established outbound peers), and the TCL paired
successfully with a hardened, current-code PearTune.app against the real 209-track library.
**The launch mechanism works on macOS, off the installed app, headless.** Measured on the
mac-mini (macOS 26.2) on 2026-08-08, against the shipped `/Applications/PearTune.app`, which is
signed with the hardened runtime already (`flags=0x10000(runtime)`, `TeamIdentifier=G79ALD29NA`):
```
ELECTRON_RUN_AS_NODE=1 PEARTUNE_DATA=... PEARTUNE_HTTP_PORT=8752 \
/Applications/PearTune.app/Contents/MacOS/PearTune \
/Applications/PearTune.app/Contents/Resources/app.asar/vendor/host/index.js
[..] folder:scanned {"tracks":209,"albums":26,"artists":6}
[..] host:announced {"topic":"b6i8ftod"}
[..] host:listening {"hostKey":"qzz316anf4wpf8mhr8bzbn5epd4owuoosfipjti8fk716ampmeio"}
```
It scanned, it listened and **it announced on the DHT** - so the same
`ELECTRON_RUN_AS_NODE` trick that made Linux and Windows cheap works here too. No second
runtime, no bundling of sodium-native / rocksdb-native / hyperdht, no display. Run as the
logged-in user with a scratch data dir on a spare port, so the live host was untouched
(confirmed still answering 200 afterwards).
**Still NOT measured, and each one can sink a slice.** These are the actual risks and none of
them should be guessed at:
1. **The system domain itself.** Everything above ran as `tim` in a GUI session. That the same
command works under `launchd`'s `system` domain as root, with nobody logged in, is the whole
premise and is untested. Needs an admin password on the mac-mini.
2. **TCC and the music folder.** The library lives in `/Users/tim/Music`. `~/Music` is not in
the default TCC-protected set the way Desktop/Documents/Downloads are, but a root daemon
reading another user's home is exactly the shape macOS has been tightening for years. If it
needs Full Disk Access, that is a checkbox in System Settings that no installer can tick, and
it changes the setup story enough to be worth knowing before any code is written.
3. **Whether a daemon can be signed and still load.** `launchd` in the system domain is fussier
than a user agent about ownership and permissions of the plist and the executable.
## Design
Mirror the Windows slice, which is the closer sibling: it already solved "the service runs as a
system account, so the data dir must leave the user's profile", and it solved it with a verified
migration rather than a copy-and-hope.
- **The daemon.** `/Library/LaunchDaemons/com.peerloom.peartune.plist`, root-owned, mode 0644,
`RunAtLoad` + `KeepAlive`, running the `ELECTRON_RUN_AS_NODE` line measured above.
- **THE DATA DIR DOES NOT MOVE.** This is a correction to the first draft of this proposal,
which said to "reuse the Windows migration wholesale". There is no Windows migration to
reuse. It was built, byte-verified, and **abandoned on hardware** (2026-08-01): hypercore-
storage stamps `store/CORESTORE` through the `device-file` package, which records the file's
**inode** and re-checks it on open, so a byte-perfect digest-verified copy still refuses to
open with `fatal: Invalid device file, was modified`, permanently. The guard is deliberate,
and it exists to catch exactly this.
The lesson recorded there is worth repeating because it is what my draft got wrong: verifying
a migration by comparing digests proves **the bytes arrived**; it does not prove **the store
opens**, and only the second claim matters.
So macOS does what Windows does: the daemon is pointed at
`~/Library/Application Support/peartune-desktop`, the directory the tray app already uses.
Nothing is copied, nothing is moved, and `device-file` never has cause to fire. Running as
root means access to that path is not in question.
This ties a machine-wide daemon to one user's profile - the same accepted trade Tim made for
Windows on 2026-08-01, for the same reason.
- **RUN AS THE USER, NOT AS ROOT.** The draft said only to "watch file ownership", with the note
that "should" was doing a lot of work in that sentence. It was: the first cut ran as root and
within minutes had left root-owned `CURRENT`, `LOG` and `MANIFEST` inside the user's own store
(hardware, 2026-08-08). Nothing broke while the daemon was up, which is exactly why it would
have gone unnoticed - the damage only lands on ROLLBACK, when the tray app comes back as the
user and cannot write files it supposedly owns. A rollback that corrupts the thing it is
rolling back is worse than no rollback.
The fix is one plist key. `UserName` decides which credentials the job runs with; living in
the SYSTEM domain rather than `gui/501` is what makes it survive logout. Those are independent,
so we keep everything slice 1 measured and drop the ownership churn entirely. Install also
repairs any root-owned files a previous version left, so a machine that ran the root cut is put
right rather than left half-changed.
- **The tray app becomes a CLIENT**, exactly as Linux slice 1 had to absorb slice 2. A daemon at
boot plus the existing login item would put two hosts on one data dir, which is the one
outcome worse than no daemon at all.
- **The music folder stays where it is.** Do not move a user's music. If TCC blocks the read,
that is a documented Full Disk Access step, not a reason to relocate their library.
## SLICE 1 RESULT, 2026-08-08: the premise holds, and all three risks cleared
Run with `desktop/scripts/macos-daemon-probe.sh` on the mac-mini (macOS 26.2), then a real
reboot with nobody logging in afterwards. Four minutes after that reboot:
```
$ uptime
15:16 up 4 mins, 0 users, load averages: 1.35 0.91 0.43
$ who
<- no console session at all
$ ps -Ao user,pid,command | grep PearTune
root 592 /Applications/PearTune.app/Contents/MacOS/PearTune .../vendor/host/index.js
$ curl -o /dev/null -w '%{http_code}' http://127.0.0.1:8752/ -> 200
probe.log, from this boot:
[20:11:20] folder:scanned {"tracks":209,"albums":26,"artists":6}
[20:11:22] host:announced {"topic":"upui34ok"}
```
- **Risk 1, the system domain: CLEARED.** It bootstrapped, and it came back by itself after a
reboot, running **as root** with **zero users logged in**.
- **Risk 2, TCC and the music folder: CLEARED, no Full Disk Access needed.** The daemon read
`/Users/tim/Music` and scanned all 209 tracks. Tim's "yes if documented" answer turns out not
to be needed.
- **Risk 3, signing and load: CLEARED.** The shipped hardened-runtime binary loaded in the
system domain unmodified.
**And the same boot demonstrated the bug, side by side.** With nobody logged in, the REAL tray
host on 8741 answered `000` - not running, because a login item needs a login - while the probe
daemon on 8752 answered 200 and was announcing on the DHT. That is the entire case for this
proposal, observed on one machine at one moment, rather than argued.
**ONE LIMIT FOUND, and it is not a blocker.** The mac-mini has **FileVault on**, so the reboot
stopped at the pre-boot unlock screen and nothing ran at all until Tim unlocked it (over ssh, as
it happens). So on a FileVault Mac the honest claim is "survives logout, and survives reboot
once the disk is unlocked" - **not** "comes back unattended after a power cut". This is not
macOS-specific and not a reason to stop: it is exactly the same on any Linux box with an
encrypted root, and the Linux slice already shipped under that same limit without noticing.
**It must be documented rather than glossed**, because "always-on" is precisely the kind of
overclaim `desktop/README.md` was corrected for in #306, and repeating it here in a new place
would be the same mistake with a fresh coat of paint.
## Slices
1. **Prove the premise. DONE 2026-08-08 - see the result above.** No product code, as intended.
2. **The daemon + the tray-as-client change**, together, for the two-hosts-on-one-data-dir
reason above. **BUILT, and verified on the mac-mini 2026-08-08:**
```
$ launchctl print system/com.peerloom.peartune -> state = running, pid 2117
$ ps -Ao user,pid,command | grep PearTune
tim 2117 /Applications/PearTune.app/Contents/MacOS/PearTune .../vendor/host/index.js
$ curl -o /dev/null -w '%{http_code}' http://127.0.0.1:8741/ -> 200
$ find "~/Library/Application Support/peartune-desktop/data" ! -user tim -> (nothing)
host key ydxww4kk4qirg4f7xzpgdwzzfk87bhoxwf4yoosrcw5rxjn6rppo
source folder @ /Users/tim/Music (209 tracks)
host.seed sha256 56dfd609a1f2f4f5 <- unchanged since 2026-08-01, before any of this
```
Running **as tim**, serving the **real** library with its **original identity**, announcing
on the DHT, and **no root-owned files left anywhere** - the repair cleaned up all five the
root cut had created.
The tray-as-client half needed no work: `adoptOrStart` keys off whether the port is already
served, not off the platform, so macOS inherited it. Confirmed by reading it.
**Two defects found by running it, both invisible to the tests:** the root-ownership one
above, and `launchctl bootout` returning BEFORE the job is actually gone - so bootstrapping
the replacement failed with the famously unhelpful `Bootstrap failed: 5: Input/output error`.
Install now waits for the label to genuinely disappear instead of trusting bootout's exit
code.
**REBOOT SURVIVAL WITH `UserName` SET: PROVEN, 2026-08-08.** Slice 1 proved it for a ROOT
daemon, and swapping to a user is not automatically the same claim, so it got its own reboot
rather than an assumption. After a real reboot with nobody logging in:
```
20:01 up 44 mins, 0 users who | grep -c console -> 0
tim 1001 /Applications/PearTune.app/Contents/MacOS/PearTune .../vendor/host/index.js
8741 -> 200
host:listening {"hostKey":"ydxww4kk4qirg4f7xzpgdwzzfk87bhoxwf4yoosrcw5rxjn6rppo"}
host:announced {"topic":"y5uxur99"} host.seed sha256 56dfd609a1f2f4f5 (unchanged)
find ... ! -user tim -> (nothing)
```
Zero console sessions, running as `tim`, serving the original library, announcing, and still
no root-owned files. **Every claim this proposal makes is now measured on hardware.**
3. ~~**The data-dir migration**, ported from Windows.~~ **CUT.** There is no migration, on
either platform, and there should not be - see the design note above. What remains of this
slice is the ownership check, which folds into slice 2's hardware verification.
4. **Docs**, and an amendment to the 2026-07-31 proposal recording that macOS came back into
scope and why. Must include the FileVault limit for **all three** platforms, not just macOS -
the Linux slice shipped with the same limit and did not say so.
## Rollback
`launchctl bootout system/com.peerloom.peartune` plus deleting the plist, and the tray app works
exactly as it does today. Rollback is genuinely cheap here **because nothing moves**: the daemon
and the tray app read the same directory, so removing the daemon leaves the library exactly
where the tray app has always expected to find it. The only thing to check on the way back is
file ownership, per the design note above.
## Open questions for Tim - ANSWERED 2026-08-08, before any measurement
Both were put to Tim before slice 1 ran, deliberately, so neither answer could be shaped by
having already done the work.
1. **Is an admin password prompt at install acceptable on macOS?** **YES.** Windows already went
this way (`perMachine`, elevated) on 2026-08-01, so the two platforms end up consistent.
2. **If TCC blocks the read, is "open System Settings and grant Full Disk Access" an acceptable
setup step?** **YES, provided it is documented clearly** - and only if the measurement shows
macOS actually blocks it, which it may not.
So slice 1 is unblocked on both counts, and neither possible outcome kills the proposal. What
slice 1 can still kill it on is the premise itself: a daemon that will not load or will not
survive a reboot.