a73x

8c4f0f8a

feat(agent): a Mac host installs the way a Mac does

a73x   2026-08-06 09:12

Commit message
feat(agent): a Mac host installs the way a Mac does

The macOS install was the Linux one with the paths changed: a binary in
/usr/local/bin, state in /var/lib/eitri-agent, a LaunchDaemon in
/Library/LaunchDaemons. None of that is how a Mac is set up, and two parts of it
did not work. An unprivileged account cannot create /var/lib, so the operator
was told to do by hand what the agent does for itself on every other platform.
And the agent upgrades by replacing its own binary, which an account that does
not own /usr/local/bin cannot do — so a Mac host installed as documented could
never take an upgrade.

The answer is not privileges. A Mac runs guests through vfkit, which needs none,
so the agent needs none either: the binary and the state go where the running
account already owns them.

--state-dir now defaults per platform. On macOS that is ~/.eitri/agent, which
the agent creates itself, and which is what the tools a Mac user already runs
do — colima keeps ~/.colima, lima ~/.lima. Not ~/Library/Application Support,
Apple's convention for application data, and the reason is measured rather than
aesthetic: vfkit's control socket lives under the state directory, macOS caps a
unix socket path at 103 usable bytes, and

	/Users/<user>/Library/Application Support/eitri-agent/vms/<32-hex>/vfkit.sock

is exactly 103 for an eight-character username. It would have worked here and
failed for the next person. The dotdir leaves 27 bytes.

A LaunchAgent rather than a LaunchDaemon, because a Mac host is nearly always
somebody's Mac, and an agent runs in the session that person is already in. The
daemon shape remains for a dedicated machine that must run guests with nobody
logged in, and the script says how: move the plist and add a UserName key.

The plist is generated rather than shipped. launchd expands neither ~ nor any
variable in ProgramArguments, so a static file cannot name a binary in a home
directory, and every path has to be resolved on the machine that will run it.
The installer also refuses a binary the account cannot write, rather than
letting a tidy-looking prefix silently disable upgrades, and stops with bootout
rather than `unload -w` — the -w flag writes to the persistent override database
and leaves the label disabled, so the next load fails for a reason that has
nothing to do with the file.

docs/quickstart.md
Old New
@@ -57,20 +57,33 @@ here: vfkit only works carrying Apple's virtualization entitlement, and an
57 entitlement lives in a code signature. Homebrew's copy is signed. Without it, 57 entitlement lives in a code signature. Homebrew's copy is signed. Without it,
58 VMs placed on this host fail at once, saying so. 58 VMs placed on this host fail at once, saying so.
59 59
60 Take the darwin bundle instead of the linux one, run `eitri-agent join` with 60 Take the darwin bundle instead of the linux one. Nothing here needs `sudo`, and
61 the blob from **+ Add host**, then install the launchd job that rides with it: 61 nothing goes in a system directory:
62 62
63 ```sh 63 ```sh
64 sudo cp sh.eitri.agent.plist /Library/LaunchDaemons/ 64 mkdir -p ~/.local/bin && cp eitri-agent ~/.local/bin/
65 sudo chown root:wheel /Library/LaunchDaemons/sh.eitri.agent.plist 65 ~/.local/bin/eitri-agent join eitri_join_<blob-from-console>
66 sudo launchctl load -w /Library/LaunchDaemons/sh.eitri.agent.plist 66 ./eitri-agent-launchagent.sh install ~/.local/bin/eitri-agent
67 ``` 67 ```
68 68
69 Set `UserName` in the plist to the account that owns the state directory before 69 State lands in `~/.eitri/agent`, logs in `~/Library/Logs/eitri-agent.log`, and
70 you load it. Unlike the Linux agent, this one does not run as root: on Linux 70 the agent starts with your session.
71 the agent owns the host's network—the bridge, the taps, `/dev/kvm`—and on macOS 71
72 none of that is eitri's, because Virtualization.framework owns the guest 72 Keep the binary somewhere this account owns. The agent replaces its own binary
73 network and vfkit carries the entitlement for it. 73 when it upgrades, so a root-owned prefix like `/usr/local/bin` would look tidy
74 and quietly disable every future upgrade—the installer refuses rather than let
75 that happen.
76
77 Unprivileged throughout, because on macOS eitri owns none of the host's
78 networking: Virtualization.framework owns the guest network and vfkit carries
79 the entitlement for it. Contrast the Linux agent, which is root because it owns
80 the bridge, the taps, `/dev/kvm` and the nftables rules.
81
82 A **LaunchAgent**, not a LaunchDaemon, because a Mac host is nearly always
83 somebody's Mac and an agent runs where that person already is. For a dedicated
84 machine that must run guests with nobody logged in, move the generated plist to
85 `/Library/LaunchDaemons` and add a `UserName` key naming this account; leave the
86 binary and state where they are.
74 87
75 Apple Silicon runs **arm64 guests only**: the framework cannot emulate another 88 Apple Silicon runs **arm64 guests only**: the framework cannot emulate another
76 architecture. You do not have to remember that—`default_images` is keyed by 89 architecture. You do not have to remember that—`default_images` is keyed by
@@ -79,8 +92,9 @@ on a Mac. If you *do* name an image, name an arm64 image; nothing downstream
79 checks, and one built for the wrong architecture boots into nothing. 92 checks, and one built for the wrong architecture boots into nothing.
80 93
81 Guests get their addresses from macOS's own NAT rather than from the agent, so 94 Guests get their addresses from macOS's own NAT rather than from the agent, so
82 a Mac's guests sit on vmnet's subnet, not on the bridge CIDR the console shows. 95 a Mac's guests sit on vmnet's subnet. The host reports that subnet to the fleet,
83 Everything above that—`eitri ssh`, the console, reconcile—is the same. 96 so it is what the console shows—no allocation is made for a Mac and none is
97 used. Everything above that—`eitri ssh`, the console, reconcile—is the same.
84 98
85 ### Boot a VM 99 ### Boot a VM
86 100
@@ -302,8 +316,8 @@ The host goes **online** in the console. Logs:
302 `journalctl -u eitri-agent -f`. 316 `journalctl -u eitri-agent -f`.
303 317
304 A Mac joins the same fleet from the `darwin_arm64` bundle—`brew install vfkit` 318 A Mac joins the same fleet from the `darwin_arm64` bundle—`brew install vfkit`
305 first, then `eitri-agent join`, then the launchd job in place of the systemd 319 first, then `eitri-agent join`, then `eitri-agent-launchagent.sh install` in
306 unit. The differences are the same ones the hosted section covers above, and 320 place of the systemd unit. The differences are the same ones the hosted section covers above, and
307 the one extra thing self-hosting asks of you is an `arm64` entry in the 321 the one extra thing self-hosting asks of you is an `arm64` entry in the
308 server's `default_images`. 322 server's `default_images`.
309 323
internal/agent/run/cli.go
Old New
@@ -92,7 +92,15 @@ func RunCLI(args []string) error {
92 // touched. 92 // touched.
93 func parseConfig(args []string) (Config, []string, error) { 93 func parseConfig(args []string) (Config, []string, error) {
94 fs := flag.NewFlagSet("eitri-agent", flag.ContinueOnError) 94 fs := flag.NewFlagSet("eitri-agent", flag.ContinueOnError)
95 stateDir := fs.String("state-dir", "/var/lib/eitri-agent", "agent state directory") 95 // The default is per-platform: a Linux host keeps state where its
96 // root-run unit can create it, a Mac in a dotdir the running account owns.
97 // See each wire_*.go — importing one platform's filesystem layout into the
98 // other is how a Mac ended up being told to create /var/lib by hand.
99 stateDirDefault, err := defaultStateDir()
100 if err != nil {
101 return Config{}, nil, err
102 }
103 stateDir := fs.String("state-dir", stateDirDefault, "agent state directory")
96 // --ch-bin/--firmware/--bootstrap-url configure the cloud-hypervisor 104 // --ch-bin/--firmware/--bootstrap-url configure the cloud-hypervisor
97 // backend and --vfkit-bin the macOS one. They are accepted everywhere and 105 // backend and --vfkit-bin the macOS one. They are accepted everywhere and
98 // ignored by platforms that don't run them, so the agent has one flag 106 // ignored by platforms that don't run them, so the agent has one flag
internal/agent/run/wire_darwin.go
Old New
@@ -4,6 +4,9 @@ package run
4 4
5 import ( 5 import (
6 "context" 6 "context"
7 "fmt"
8 "os"
9 "path/filepath"
7 10
8 "github.com/a73x/eitri/internal/agent/reconcile" 11 "github.com/a73x/eitri/internal/agent/reconcile"
9 "github.com/a73x/eitri/internal/agent/serialpump" 12 "github.com/a73x/eitri/internal/agent/serialpump"
@@ -15,6 +18,31 @@ import (
15 // time — an opaque label the server stores and never interprets. 18 // time — an opaque label the server stores and never interprets.
16 const platformProvisioner = "vfkit" 19 const platformProvisioner = "vfkit"
17 20
21 // defaultStateDir is where a Mac agent keeps its state when nothing says
22 // otherwise: a dotdir in the running account's home, which is what the tools a
23 // Mac user already runs do (colima keeps ~/.colima, lima ~/.lima). Not
24 // /var/lib/eitri-agent, which is a Linux path an unprivileged account cannot
25 // create; and not ~/Library/Application Support, Apple's convention for
26 // application data, for a measured reason: vfkit's control socket lives under
27 // the state directory, macOS caps a unix socket path at 103 usable bytes, and
28 //
29 // /Users/<user>/Library/Application Support/eitri-agent/vms/<32-hex>/vfkit.sock
30 //
31 // is exactly 103 for an eight-character username. It would work for the author
32 // of this line and fail for the next person. The dotdir leaves 27 bytes of
33 // headroom, and Preflight still refuses outright rather than failing later if a
34 // deeper --state-dir spends them.
35 //
36 // An unresolvable home is not worth guessing at: the caller sees the error and
37 // the operator passes --state-dir.
38 func defaultStateDir() (string, error) {
39 home, err := os.UserHomeDir()
40 if err != nil {
41 return "", fmt.Errorf("locate home directory for the default --state-dir: %w", err)
42 }
43 return filepath.Join(home, ".eitri", "agent"), nil
44 }
45
18 // platform is this host's backend pair, mirroring wire_linux.go's contract: 46 // platform is this host's backend pair, mirroring wire_linux.go's contract:
19 // serve() is the sole consumer, wiring Prov and Pumps into the reconcile engine 47 // serve() is the sole consumer, wiring Prov and Pumps into the reconcile engine
20 // and the sync client. 48 // and the sync client.
internal/agent/run/wire_linux.go
Old New
@@ -47,6 +47,11 @@ type platform struct {
47 GuestCIDR func() string 47 GuestCIDR func() string
48 } 48 }
49 49
50 // defaultStateDir is where a Linux agent keeps its state when nothing says
51 // otherwise. The unit runs as root and the agent creates the directory itself,
52 // so there is nothing for an operator to pre-create.
53 func defaultStateDir() (string, error) { return "/var/lib/eitri-agent", nil }
54
50 // lastResortGuestCIDR is used only when nothing else produced one: no persisted 55 // lastResortGuestCIDR is used only when nothing else produced one: no persisted
51 // identity, no flag, and no suggestion from the control plane. It is a last 56 // identity, no flag, and no suggestion from the control plane. It is a last
52 // resort and not "the default" — a host that reaches it is a host the fleet 57 // resort and not "the default" — a host that reaches it is a host the fleet
scripts/eitri-agent-launchagent.sh
Old New
@@ -0,0 +1,117 @@
1 #!/usr/bin/env bash
2 #
3 # Install this Mac's eitri-agent as a LaunchAgent, so it starts with the
4 # session and is restarted if it crashes. The macOS counterpart of
5 # eitri-agent.service.
6 #
7 # ./eitri-agent-launchagent.sh install [/path/to/eitri-agent]
8 # ./eitri-agent-launchagent.sh uninstall
9 # ./eitri-agent-launchagent.sh status
10 #
11 # No privileges anywhere. A Mac runs guests through vfkit, which needs none, so
12 # the agent needs none either — and the two things every agent does need, a
13 # state directory it can create and a binary it can replace when it upgrades,
14 # are both satisfied by keeping them in the running account's own space.
15 #
16 # A LaunchAgent and not a LaunchDaemon, because a Mac host is almost always
17 # somebody's Mac. An agent runs in the login session, which is where the person
18 # using that machine already is. A dedicated always-on host that must run guests
19 # with nobody logged in wants a LaunchDaemon instead — same plist, moved to
20 # /Library/LaunchDaemons with a UserName key naming this account, and the
21 # binary and state left exactly where they are here.
22 #
23 # A generated plist rather than a shipped one: launchd does not expand ~ or any
24 # variable in ProgramArguments, so every path has to be absolute and resolved on
25 # the machine it will run on.
26 set -euo pipefail
27
28 LABEL="sh.eitri.agent"
29 PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
30 LOG_DIR="$HOME/Library/Logs"
31 DOMAIN="gui/$(id -u)"
32
33 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
34
35 cmd_install() {
36 local bin="${1:-}"
37 if [[ -z $bin ]]; then
38 bin="$(command -v eitri-agent || true)"
39 [[ -n $bin ]] || die "eitri-agent is not on PATH — pass its path: $0 install /path/to/eitri-agent"
40 fi
41 bin="$(cd "$(dirname "$bin")" && pwd)/$(basename "$bin")"
42 [[ -x $bin ]] || die "not executable: $bin"
43
44 # The agent replaces this file when it upgrades itself, so the account
45 # running it has to own it. A root-owned prefix like /usr/local/bin looks
46 # tidy and quietly disables every future upgrade.
47 [[ -w $bin ]] || die "$bin is not writable by $(id -un) — self-upgrade would fail. Keep the binary somewhere this account owns."
48
49 command -v vfkit >/dev/null || printf 'warning: vfkit is not on PATH; guests will fail Preflight until `brew install vfkit`\n' >&2
50
51 mkdir -p "$(dirname "$PLIST")" "$LOG_DIR"
52 cat > "$PLIST" <<-PLIST
53 <?xml version="1.0" encoding="UTF-8"?>
54 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
55 <plist version="1.0">
56 <dict>
57 <key>Label</key>
58 <string>$LABEL</string>
59 <key>ProgramArguments</key>
60 <array>
61 <string>$bin</string>
62 </array>
63 <key>RunAtLoad</key>
64 <true/>
65 <!-- Restart a crash, but let a clean stop be a stop: the agent exits 0
66 on SIGTERM, so bootout stays a stop rather than a fight. -->
67 <key>KeepAlive</key>
68 <dict>
69 <key>SuccessfulExit</key>
70 <false/>
71 </dict>
72 <key>ThrottleInterval</key>
73 <integer>5</integer>
74 <!-- Homebrew's directory is absent from a launchd job's default PATH,
75 and vfkit lives there. Without this the agent starts and then
76 refuses every create, correctly but avoidably. -->
77 <key>EnvironmentVariables</key>
78 <dict>
79 <key>PATH</key>
80 <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
81 </dict>
82 <key>StandardOutPath</key>
83 <string>$LOG_DIR/eitri-agent.log</string>
84 <key>StandardErrorPath</key>
85 <string>$LOG_DIR/eitri-agent.log</string>
86 </dict>
87 </plist>
88 PLIST
89 plutil -lint "$PLIST" >/dev/null || die "generated plist is malformed: $PLIST"
90
91 # bootout first so install is idempotent; ignore "not loaded".
92 launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
93 launchctl bootstrap "$DOMAIN" "$PLIST"
94 printf 'installed %s\n binary %s\n log %s/eitri-agent.log\n' "$PLIST" "$bin" "$LOG_DIR"
95 cmd_status
96 }
97
98 cmd_uninstall() {
99 # bootout, NOT `unload -w`: the -w flag writes to the persistent override
100 # database and leaves the label DISABLED, so every later load fails with an
101 # error that says nothing about why.
102 launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
103 rm -f "$PLIST"
104 printf 'removed %s (state and binary left alone)\n' "$PLIST"
105 }
106
107 cmd_status() {
108 launchctl print "$DOMAIN/$LABEL" 2>/dev/null | grep -E '^\s+(state|pid|last exit code) ' ||
109 printf 'not loaded\n'
110 }
111
112 case "${1:-}" in
113 install) shift; cmd_install "$@" ;;
114 uninstall) cmd_uninstall ;;
115 status) cmd_status ;;
116 *) sed -n '3,10p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
117 esac
scripts/release.sh
Old New
@@ -1,7 +1,7 @@
1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/: 2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/:
3 # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit 3 # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit
4 # eitri_<v>_darwin_arm64.tar.gz Mac host bundle: agent + launchd job 4 # eitri_<v>_darwin_arm64.tar.gz Mac host bundle: agent + LaunchAgent installer
5 # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin 5 # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin
6 # eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit 6 # eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit
7 # (optional sidecar; not in manifest.json) 7 # (optional sidecar; not in manifest.json)
@@ -62,7 +62,10 @@ for arch in amd64 arm64; do
62 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}" 62 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}"
63 done 63 done
64 64
65 # macOS host bundle: the agent and its launchd job. arm64 ONLY, and that is a 65 # macOS host bundle: the agent and the script that installs it as a LaunchAgent.
66 # A generated plist rather than a shipped one, because launchd expands nothing in
67 # ProgramArguments — every path must be absolute and resolved on the machine that
68 # will run it. arm64 ONLY, and that is a
66 # claim about what has booted a guest, not about what compiles — a darwin/amd64 69 # claim about what has booted a guest, not about what compiles — a darwin/amd64
67 # binary would put an upgrade button in front of an Intel Mac we have never run. 70 # binary would put an upgrade button in front of an Intel Mac we have never run.
68 # Without one, such a host gets "no eitri-agent artifact for darwin/amd64", 71 # Without one, such a host gets "no eitri-agent artifact for darwin/amd64",
@@ -79,7 +82,7 @@ mkdir -p "$mac_stage/$mac_bundle"
79 echo "==> building darwin/arm64" 82 echo "==> building darwin/arm64"
80 CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \ 83 CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \
81 -o "$mac_stage/$mac_bundle/eitri-agent" ./cmd/eitri-agent 84 -o "$mac_stage/$mac_bundle/eitri-agent" ./cmd/eitri-agent
82 cp scripts/sh.eitri.agent.plist "$mac_stage/$mac_bundle/" 85 cp scripts/eitri-agent-launchagent.sh "$mac_stage/$mac_bundle/"
83 tar -C "$mac_stage" -czf "$OUT/$mac_bundle.tar.gz" "$mac_bundle" 86 tar -C "$mac_stage" -czf "$OUT/$mac_bundle.tar.gz" "$mac_bundle"
84 cp "$mac_stage/$mac_bundle/eitri-agent" "$OUT/eitri-agent_darwin_arm64" 87 cp "$mac_stage/$mac_bundle/eitri-agent" "$OUT/eitri-agent_darwin_arm64"
85 88
scripts/sh.eitri.agent.plist
Old New
@@ -1,72 +0,0 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 <!--
3 eitri-agent launchd job — the macOS counterpart of eitri-agent.service.
4 Install on each Mac host:
5
6 sudo cp sh.eitri.agent.plist /Library/LaunchDaemons/
7 sudo chown root:wheel /Library/LaunchDaemons/sh.eitri.agent.plist
8 sudo launchctl load -w /Library/LaunchDaemons/sh.eitri.agent.plist
9
10 A LaunchDaemon, not a LaunchAgent: a fleet host has to run its guests without
11 anyone logged in, and a LaunchAgent only exists inside a login session.
12
13 UserName is deliberate, and the opposite of the Linux unit's User=root. The
14 Linux agent needs root because it owns the host's network — /dev/kvm and
15 /dev/net/tun, the bridge, the taps, nftables, DHCP on :67. On macOS none of
16 that is eitri's: Virtualization.framework owns the guest network through
17 vmnet, and vfkit carries the entitlement that grants it. Set UserName to the
18 account that owns the state directory and has vfkit on its PATH.
19
20 KeepAlive replaces Restart=on-failure. Agent self-upgrades never need it —
21 the agent re-execs in place and keeps its PID, so launchd sees one
22 uninterrupted job. SuccessfulExit=false restarts a crash but not a clean
23 stop, so `launchctl unload` stays a stop rather than a fight.
24
25 There is no macOS equivalent of KillMode=process, and none is needed: launchd
26 does not place children in a job-wide kill group the way a systemd cgroup
27 does, so stopping the agent leaves running guests alone. Guests survive an
28 agent restart on macOS for the same reason they do on Linux — the agent
29 re-adopts them by boot-scoped pidfile.
30 -->
31 <plist version="1.0">
32 <dict>
33 <key>Label</key>
34 <string>sh.eitri.agent</string>
35
36 <key>ProgramArguments</key>
37 <array>
38 <string>/usr/local/bin/eitri-agent</string>
39 <string>--state-dir</string>
40 <string>/usr/local/var/lib/eitri-agent</string>
41 </array>
42
43 <!-- The account that owns the state directory. Guests run as this user. -->
44 <key>UserName</key>
45 <string>CHANGEME</string>
46
47 <key>RunAtLoad</key>
48 <true/>
49 <key>KeepAlive</key>
50 <dict>
51 <key>SuccessfulExit</key>
52 <false/>
53 </dict>
54 <key>ThrottleInterval</key>
55 <integer>5</integer>
56
57 <!-- Homebrew's bin directory is not on a LaunchDaemon's default PATH, and
58 vfkit lives there. Without this the agent starts and then refuses every
59 create in Preflight, which is correct but avoidable. Apple silicon
60 Homebrew is /opt/homebrew; an Intel install is /usr/local. -->
61 <key>EnvironmentVariables</key>
62 <dict>
63 <key>PATH</key>
64 <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
65 </dict>
66
67 <key>StandardOutPath</key>
68 <string>/usr/local/var/log/eitri-agent.log</string>
69 <key>StandardErrorPath</key>
70 <string>/usr/local/var/log/eitri-agent.log</string>
71 </dict>
72 </plist>