Merge nucleic/vivid-glass-urchin-xoym into main

This commit is contained in:
2026-08-07 16:38:56 -07:00
parent 902bea5091
commit 26739f9487
12 changed files with 1214 additions and 174 deletions
+27 -5
View File
@@ -612,7 +612,7 @@ adopting it would require per-slot saved states and a careful look at DHCP lease
reuse — not a drop-in optimization.
**16. macOS 15+ Local Network privacy can block host→guest connections, and
there is no reliable way to grant it interactively here.** Per
granting it interactively takes deliberate work.** Per
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
it is not TCC — the check is a Network Extension packet filter, so it is absent
from `TCC.db`, cannot be queried, cannot be reset, and it "uses your main
@@ -628,8 +628,30 @@ rebuild.
(`com.apple.network.local-network`, keys `AllowedEthernetLocalNetworkAddresses`
and `AllowedWiFiLocalNetworkAddresses`), which is keyed on the network rather
than the app and is read at boot — so it needs a reboot, not a service restart.
`Doctor.checkLocalNetwork` reads those keys and requires coverage of
`LocalNetworkPolicy` owns the arithmetic and requires coverage of
`192.168.64.0/18`, not a single /24, because the NAT subnet is chosen at runtime
and slides to the next free /24. `SSHExec.localNetworkHint` appends the same
guidance to connection failures, since errno 65 gives the operator nothing to go
on by itself.
and slides to the next free /24; `Doctor.localNetworkNote` and
`SSHExec.localNetworkHint` both report against it, since errno 65 gives the
operator nothing to go on by itself.
→ *Consequence:* both routes are commands rather than documentation.
`permissions grant` writes the allowlist and verifies it read back (`sudo
defaults write` lands in root's or the invoking user's preferences depending on
whether sudo preserved `HOME`, so where it went is not assumable).
`permissions grant --method prompt` addresses the attribution problem head-on:
launching the installed bundle through LaunchServices (`open -n -b …`) makes the
app its **own** responsible process, so the prompt and the Settings row belong to
it rather than to Terminal — and because the LaunchAgent runs the same signed
identity, the grant carries. That only became worth building once the bundle was
Developer ID signed; under ad-hoc signing the UUID churn withdraws it on the next
rebuild, which is why `permissions status` reports code identity alongside the
allowlist.
→ *Consequence:* the prompt route is best-effort and says so. Observed on a host
where the decision was already recorded: `UserEventAgent` resolves the flow to
the bundle ID on every attempt — so the attribution works — but presents no
alert, because macOS asks once per app identity and then answers from that
record, silently, forever. There is no supported reset. So `--method prompt`
verifies by *probing* rather than by trusting the launch, and on a denial says
plainly that it did not take and points at the allowlist, which is not subject
to the per-app check at all. The allowlist stays the recommendation.
+48 -29
View File
@@ -441,6 +441,11 @@ disabling sleep (`sudo pmset -a sleep 0 disablesleep 1`) so the Mac comes back i
after a power event without a human present. This does mean the disk is effectively unlocked at
boot — appropriate for a dedicated CI machine, not for a shared workstation.
On a terminal, `service install` also asks whether to configure Local Network access (§2.6) when it
is not already, defaulting to no. It never blocks: a scripted install with no terminal prints a
pointer and carries on. `--grant-local-network allowlist|prompt|none` decides it up front instead of
being asked.
`service uninstall` removes the LaunchAgent; it does not delete images or config.
### 2.6 macOS 15+ Local Network privacy prompt
@@ -449,32 +454,41 @@ Starting with macOS 15, a process that contacts other hosts on the local network
one-time Local Network permission prompt. A LaunchAgent that is denied (or that never gets a human
to click Allow) cannot reach the guest's NAT address, so VMs boot but SSH never connects.
**On a CI host, use the subnet allowlist.** It is the only deterministic option — no prompt, no GUI
session, and nothing to redo after a rebuild:
`service install` offers to configure this, and it can also be done at any time:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
gitea-macos-runner permissions status # what is configured, and what to do about it
gitea-macos-runner permissions grant # configure it
```
Then **reboot** — these are read at boot, so restarting the service alone is not enough.
`grant` has two methods. Both are one command; neither needs anything pasted.
**Do not pin this to a single /24.** Virtualization.framework's NAT starts at `192.168.64.0/24` but
picks the subnet at runtime and steps to the next free one when that range is already in use, so the
same host can hand out `192.168.65.x` tomorrow. An allowlist naming only `192.168.64.0/24` then
looks configured while silently blocking every guest — the failure surfaces as `No route to host`
(errno 65) on the SSH connection, not as a permission error. The `/18` above spans
`192.168.64.0`–`192.168.127.255`, which covers the drift; if you would rather not think about
ranges at all, the RFC 1918 set `"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor`
reports `local network access` as a **pass** once it sees an allowlist that covers that span, and as
a **warning** when an allowlist exists but does not. Both keys are documented by Apple in
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
and are the workaround [Tart's FAQ](https://tart.run/faq/) recommends for the same problem.
**`--method allowlist` (the default) is what a CI host wants.** It writes a subnet allowlist — the
one deterministic option: no prompt, no GUI session, and nothing to redo after a rebuild. It asks
for your sudo password, reports which preferences file the write actually landed in, and then offers
to **reboot**, which is required: these values are read at boot, so restarting the service alone is
not enough. Pass `--no-reboot` to defer that.
**Why not just approve the prompt?** Because on this host there is usually nothing able to show it,
and when something does, it is attributed to the wrong program.
The default grant is all of RFC 1918 — `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` — the same
set [Tart](https://tart.run/faq/) and orchard use. Narrow it with `--subnet`, repeatable, but **do
not pin it to a single /24**: Virtualization.framework's NAT starts at `192.168.64.0/24` but picks
the subnet at runtime and steps to the next free one when that range is already in use, so the same
host can hand out `192.168.65.x` tomorrow. An allowlist naming only `192.168.64.0/24` then looks
configured while silently blocking every guest — and the failure surfaces as `No route to host`
(errno 65) on the SSH connection, not as a permission error. `192.168.64.0/18` spans
`192.168.64.0`–`192.168.127.255`, which is the narrowest entry that covers the drift. `doctor`
reports `local network access` as a **pass** once it sees an allowlist covering that span, and as a
**warning** when an allowlist exists but does not. Both keys are documented by Apple in
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
**`--method prompt` takes effect immediately, with no reboot**, and is the better choice on a Mac
you are sitting in front of. It launches the installed `.app` through LaunchServices — which is what
makes the app its own responsible process — provokes the real system alert, and reports whether the
grant took. It needs `make install` to have run, a GUI session to show the alert in, and a Developer
ID signature for the grant to survive the next rebuild; `permissions status` reports that last one.
**Why the prompt needs that much machinery.** Left to itself, on this host there is usually nothing
able to show it, and when something does, it is attributed to the wrong program.
An app appears under **System Settings → Privacy & Security → Local Network** only *after* it has
attempted a connection to a guest, so an empty list on a fresh install is expected and means
@@ -490,14 +504,19 @@ miss:
approving it there does not carry over to the LaunchAgent. (If you are hunting for a row that
seems missing, look for Terminal rather than for "Gitea macOS Runner".)
> **And under ad-hoc signing it is not durable anyway.** Local Network privacy does not use TCC; per
> TN3179 it "uses your main executable UUID as part of its implementation", and the linker mints a
> fresh `LC_UUID` on essentially every rebuild. So `make install` after a code change presents as a
> new app that must be approved again — and macOS offers no way to reset a Local Network decision
> back to undetermined, so stale entries accumulate. Signing with a Developer ID certificate fixes
> the churn (see [Code signing](#code-signing)); the subnet allowlist above, keyed on the network
> rather than on the app, sidesteps the whole mechanism and is the recommendation for an unattended
> machine either way.
`permissions grant --method prompt` exists to thread that needle: it starts the app through
LaunchServices rather than from the shell, so the app is its own responsible process and the
decision is recorded against *its* identity — the same identity the LaunchAgent runs under.
> **Under an ad-hoc signature it is not durable anyway.** Local Network privacy does not use TCC;
> per TN3179 it "uses your main executable UUID as part of its implementation", and the linker mints
> a fresh `LC_UUID` on essentially every rebuild. So `make install` after a code change presents as
> a new app that must be approved again — and macOS offers no way to reset a Local Network decision
> back to undetermined, so stale entries accumulate. A Developer ID signature fixes the churn, since
> the identity is then anchored to the certificate rather than to the binary (see
> [Code signing](#code-signing)) — that is what makes `--method prompt` worth using at all. The
> subnet allowlist, keyed on the network rather than on the app, sidesteps the whole mechanism and
> remains the recommendation for an unattended machine.
---
@@ -531,7 +550,7 @@ The checks, in order:
| `runner download url` | The `gitea-runner` release asset is reachable |
| `token file permissions` | Warns — not fails — when a token file is group- or world-readable |
| `guest ssh` | Reachability of the most recent guest lease, when there is one. Warns on a timeout, which is most often a stale 24-hour lease for a guest that is already gone |
| `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
| `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt. Fix either with `permissions grant` (§2.6) |
If the config file is missing or invalid, the host checks still run and the rest
are skipped — which is exactly the state a first-time operator is in. Resolve
+59 -46
View File
@@ -23,12 +23,12 @@ gitea-macos-runner service status
| --- | --- | --- |
| VM won't start; entitlement / `com.apple.security.virtualization` error | Running an unsigned binary, or one outside the signed `.app` bundle | `make sign` (or re-run `make install`); invoke the installed bundle, never `.build/release/…` |
| `virtualMachineLimitExceeded` at boot | macOS allows at most **2** concurrent macOS VMs | Set `scheduler.maxConcurrentVMs` ≤ 2; kill stray VMs from earlier runs |
| VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; grant Local Network permission or pre-authorize the subnet |
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection, and a run started from a shell is attributed to Terminal, not to the app | Allowlist the subnet with `defaults write com.apple.network.local-network` and reboot; the interactive grant does not reach the LaunchAgent |
| VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; `gitea-macos-runner permissions grant` |
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection, and a run started from a shell is attributed to Terminal, not to the app | `gitea-macos-runner permissions grant` (allowlist, then reboot), or `--method prompt` to raise the alert as the app rather than as Terminal |
| SSH times out on a freshly built image | Guest macOS < 27, so provisioning options were ignored and Setup Assistant is waiting | Rebuild the image from a macOS **27+** IPSW |
| VMs boot in a loop; every teardown says `reason=cancelled` and nothing is logged between the lease and the teardown | `scheduler.bootTimeoutSeconds` is below the guest's *worst-case* boot on a contended host, so each clone is killed while still starting — and each replacement makes the next one slower | Raise `scheduler.bootTimeoutSeconds` (default 900) and reduce the number of concurrent guests; see [The daemon boots VMs forever](#the-daemon-boots-vms-forever-and-every-teardown-says-reasoncancelled) |
| `ssh failed: cannot connect … No route to host) (errno: 65)` part-way through provisioning | macOS 15+ Local Network privacy blocking the app — the grant is keyed on the executable's UUID, so `make install` withdraws it | Allowlist the subnet (`192.168.64.0/18`) and **reboot**; see [SSH fails with "No route to host" mid-run](#ssh-fails-with-no-route-to-host-errno-65-mid-run) |
| Allowlist is set but guests are still unreachable | It names `192.168.64.0/24` while the NAT has moved to `192.168.65.x` | Widen it to `192.168.64.0/18` and reboot; `doctor` now warns about too-narrow allowlists |
| `ssh failed: cannot connect … No route to host) (errno: 65)` part-way through provisioning | macOS 15+ Local Network privacy blocking the app — the grant is keyed on the executable's UUID, so `make install` withdraws it | `gitea-macos-runner permissions grant`, then **reboot**; see [SSH fails with "No route to host" mid-run](#ssh-fails-with-no-route-to-host-errno-65-mid-run) |
| Allowlist is set but guests are still unreachable | It names `192.168.64.0/24` while the NAT has moved to `192.168.65.x` | `gitea-macos-runner permissions grant` (defaults to all of RFC 1918) and reboot; `doctor` warns about too-narrow allowlists |
| `SecKeyCreateRandomKey` / "Interaction is not allowed" | `login.keychain` is locked — no GUI session | Run as a LaunchAgent in an unlocked GUI session; enable auto-login |
| Job stays queued forever | Label mismatch, or the daemon isn't running/reaching Gitea | Use bare label names in `runs-on`; match `runner.labels`; check daemon logs |
| `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision <name>` |
@@ -105,26 +105,24 @@ prompt**: a LaunchAgent that was never granted permission (or was denied) cannot
An entry with a recent `lease` timestamp and the guest's MAC means networking is fine and the
problem is timing — raise `scheduler.bootTimeoutSeconds`.
2. No entry at all: pre-authorize the VM subnet, then **reboot** (these are read at boot):
2. No entry at all: check and fix the permission.
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
gitea-macos-runner permissions status
gitea-macos-runner permissions grant # then reboot when it offers
```
The `/18` is deliberate: the NAT subnet is chosen at runtime and slides to the next free /24
(192.168.65.x, .66.x, …) when one is taken, so a pinned `192.168.64.0/24` breaks the day it
moves. `doctor` reports `local network access` as a pass once it sees an allowlist covering that
span. This is the deterministic fix for an unattended host —
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
for why the interactive grant is not.
`grant` pre-authorizes the VM subnets and offers to reboot, which is required — the allowlist is
read at boot. It grants all of RFC 1918 by default; `--subnet` narrows it, but nothing narrower
than `192.168.64.0/18` is safe, because the NAT subnet is chosen at runtime and slides to the next
free /24 (192.168.65.x, .66.x, …) when one is taken, so a pinned `192.168.64.0/24` breaks the day
it moves. `doctor` reports `local network access` as a pass once it sees an allowlist covering
that span.
Do not reach for the interactive grant instead. Booting a VM by hand from a Terminal makes the
prompt (and the **System Settings → Privacy & Security → Local Network** row) belong to
*Terminal* rather than to this app, and approving it there does not carry over to the
LaunchAgent.
This is the deterministic fix for an unattended host. On a Mac with someone in front of it,
`permissions grant --method prompt` applies immediately with no reboot —
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
for what it does and why granting the prompt by hand does not work.
---
@@ -156,23 +154,36 @@ gitea-macos-runner vm boot --image default
prompt and the Settings row belong to Terminal, and allowing it there does **not** carry over to the
LaunchAgent, which is the process that actually needs it. Meanwhile the LaunchAgent itself has no UI
to show a prompt in, so from it the connection is denied outright and surfaces as `No route to host`
(errno 65) rather than as a permission error. Between the two, there is no reliable way to grant
this interactively on an unattended host.
(errno 65) rather than as a permission error.
**Fix — deterministic, and what to use on any host running the LaunchAgent.** Allowlist the VM
subnet instead. It is keyed on the network rather than on the app, so no prompt is involved, and
nothing needs redoing:
subnets instead. The allowlist is keyed on the network rather than on the app, so no prompt is
involved and nothing needs redoing:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
gitea-macos-runner permissions grant
```
Reboot afterwards. `doctor` then reports `local network access` as a **pass**. Both keys are
Apple's, documented in TN3179; the same pair is what [Tart's FAQ](https://tart.run/faq/) recommends
for this exact problem on CI hosts.
That asks for your sudo password, writes both of Apple's keys (documented in TN3179 — the same pair
[Tart's FAQ](https://tart.run/faq/) recommends for this exact problem on CI hosts), reports which
preferences file the write landed in, and offers to reboot. Reboot is required: the values are read
at boot. `doctor` then reports `local network access` as a **pass**.
**On a Mac you are sitting at,** `permissions grant --method prompt` is the alternative, and it
needs no reboot. It launches the installed `.app` through LaunchServices instead of exec'ing it from
the shell, which is exactly what makes the app its own responsible process — so the alert, and the
Settings row it creates, belong to the app rather than to Terminal, and the decision applies to the
LaunchAgent. It requires `make install` to have run, a GUI session, and a Developer ID signature to
be durable (see below); `permissions status` reports all three.
**If `--method prompt` reports "still blocked" and you never saw an alert,** macOS most likely
already has a decision on file for the app. It prompts exactly once per app identity and then
answers from that record forever — silently, with `EHOSTUNREACH`, and with no supported way to reset
it back to undetermined. The app *is* being evaluated under its own identity at that point (you can
confirm with `log show --last 2m --predicate 'subsystem == "com.apple.networkextension"'`, which
names the bundle ID on every attempt); the system simply is not asking. Switch the row on in
**System Settings → Privacy & Security → Local Network**, or use the allowlist, which bypasses the
per-app check entirely.
> **And an ad-hoc signed bundle cannot hold the grant anyway.** TN3179 notes that "local network
> privacy uses your main executable UUID as part of its implementation". An ad-hoc signature has no
@@ -636,27 +647,29 @@ Two details make it look intermittent rather than like a permission problem:
responsible process, so the app's own identity is not what is being evaluated when you launch it
by hand — and a grant given to Terminal does nothing for the LaunchAgent.
**Fix.** Allowlist the subnet — it is keyed on the network, not on the app, so no rebuild can
withdraw it and no prompt has to be answered:
**Fix.** Allowlist the subnets — the allowlist is keyed on the network, not on the app, so no
rebuild can withdraw it and no prompt has to be answered:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
sudo reboot
gitea-macos-runner permissions grant
```
The values are only read at boot, so **the reboot is not optional** — until it happens, `defaults
read com.apple.network.local-network` shows the new setting while the filter still behaves as
before.
It writes both of Apple's keys with sudo, verifies the values read back, and offers to reboot. The
values are only read at boot, so **the reboot is not optional** — until it happens, `defaults read
com.apple.network.local-network` shows the new setting while the filter still behaves as before.
Use `/18`, not `/24`. Virtualization.framework's NAT starts at `192.168.64.0/24` but chooses the
subnet at runtime and steps to the next free /24 when that one is in use, so hosts drift to
`192.168.65.x` and beyond. An allowlist naming a single /24 that the NAT has since moved off is the
worst case: it reads as configured, `doctor` used to call it a pass, and every guest connection
still fails with errno 65. `doctor` now warns instead when the allowlist does not cover
`192.168.64.0`–`192.168.127.255`.
The default grant is all of RFC 1918. If you narrow it with `--subnet`, use `/18`, not `/24`.
Virtualization.framework's NAT starts at `192.168.64.0/24` but chooses the subnet at runtime and
steps to the next free /24 when that one is in use, so hosts drift to `192.168.65.x` and beyond. An
allowlist naming a single /24 that the NAT has since moved off is the worst case: it reads as
configured, `doctor` used to call it a pass, and every guest connection still fails with errno 65.
`doctor` now warns instead when the allowlist does not cover `192.168.64.0`–`192.168.127.255`, and
`permissions grant` warns at the point you ask for something that narrow.
**If you are at the machine and would rather not reboot,** `permissions grant --method prompt`
launches the installed app through LaunchServices so the system alert is attributed to the app
rather than to Terminal, and takes effect immediately. It needs a GUI session and a Developer ID
signature to stick across rebuilds.
**Verifying.** After the reboot, `gitea-macos-runner doctor` should show `local network access` as a
pass naming the range. Re-run the command that failed; nothing else needs redoing, and