Merge nucleic/mellow-dewy-falcon-rjhr into main

This commit is contained in:
2026-08-07 01:14:28 -07:00
parent 6a867e536a
commit 8c410cf841
4 changed files with 163 additions and 28 deletions
+9
View File
@@ -38,6 +38,15 @@
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>26.0</string> <string>26.0</string>
<!--
Shown in the macOS 15+ Local Network permission prompt. The runner reaches
each guest VM over the host-private NAT link (SSH on 192.168.64.0/24) to
install and start the Gitea runner agent; without this access every VM
boots but no job ever starts.
-->
<key>NSLocalNetworkUsageDescription</key>
<string>Gitea macOS Runner connects to the virtual machines it starts on this Mac, over the host-private network link, to run your CI jobs.</string>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string></string> <string></string>
</dict> </dict>
+60 -8
View File
@@ -92,10 +92,11 @@ public enum Doctor {
/// same verb the real download uses, because the presigned redirect /// same verb the real download uses, because the presigned redirect
/// target is signed per method. Catches a version bump that no longer /// target is signed per method. Catches a version bump that no longer
/// has a darwin-arm64 asset. /// has a darwin-arm64 asset.
/// 10. **Local Network privacy note** (informational). On macOS 15+ the /// 10. **Local Network privacy**. Passes when a subnet allowlist is set in
/// first attempt to reach a guest over the NAT link can be blocked by /// `com.apple.network.local-network`; otherwise informational. On
/// the Local Network permission prompt, which a background agent cannot /// macOS 15+ the first attempt to reach a guest over the NAT link can
/// answer; the operator must approve the app once. /// be blocked by the Local Network permission prompt, which a
/// background agent cannot answer.
/// ///
/// - Parameter config: Validated configuration. Gitea-dependent checks are /// - Parameter config: Validated configuration. Gitea-dependent checks are
/// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set. /// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set.
@@ -523,19 +524,70 @@ public enum Doctor {
} }
/// The macOS 15+ Local Network permission note. /// The macOS 15+ Local Network permission note.
///
/// Reports `.pass` when the host carries a subnet allowlist, because that
/// bypasses the prompt entirely. Otherwise it stays informational: we
/// cannot see the grant itself, since Local Network privacy is a Network
/// Extension packet filter rather than a TCC entry, so there is no
/// database to query and `tccutil` does not apply (Apple, TN3179).
public static func localNetworkNote() -> DoctorCheck { public static func localNetworkNote() -> DoctorCheck {
DoctorCheck( let name = "local network access"
name: "local network access", let allowed = localNetworkAllowlist()
if !allowed.isEmpty {
return DoctorCheck(
name: name,
result: .pass,
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
)
}
return DoctorCheck(
name: name,
result: .info, result: .info,
detail: "guests are reached over the host-private NAT link", detail: "guests are reached over the host-private NAT link",
remediation: """ remediation: """
on macOS 15+ the first connection to a guest can be blocked by the Local Network \ on macOS 15+ the first connection to a guest can be blocked by the Local Network \
privacy prompt, which a background LaunchAgent cannot answer. Approve the app once \ privacy prompt, which a background LaunchAgent cannot answer. The app cannot be \
under System Settings → Privacy & Security → Local Network. pre-approved: it only appears under System Settings → Privacy & Security → Local \
Network once it has actually attempted a guest connection. To trigger and answer \
the prompt by hand, run `gitea-macos-runner vm boot --image default` once from a \
Terminal in the GUI session. On an unattended CI host prefer the subnet \
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \
"192.168.64.0/24" (then reboot). See docs/setup.md §2.6.
""" """
) )
} }
/// Subnets pre-authorized for local network access on this host, if any.
///
/// Best effort and never fatal: an unreadable or absent preferences file
/// simply reads as "no allowlist". The domain is written with `sudo`, so
/// which preferences directory it lands in depends on whether that `sudo`
/// preserved `HOME` — check each candidate rather than guess.
static func localNetworkAllowlist() -> [String] {
let keys = ["AllowedEthernetLocalNetworkAddresses", "AllowedWiFiLocalNetworkAddresses"]
let candidates = [
"/var/root/Library/Preferences/com.apple.network.local-network.plist",
"/Library/Preferences/com.apple.network.local-network.plist",
NSHomeDirectory() + "/Library/Preferences/com.apple.network.local-network.plist",
]
var found: [String] = []
for path in candidates {
guard let data = FileManager.default.contents(atPath: path),
let plist = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil) as? [String: Any]
else { continue }
for key in keys {
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
found.append(entry)
}
}
}
return found
}
/// Renders checks as aligned, human-readable lines for the CLI. /// Renders checks as aligned, human-readable lines for the CLI.
public static func format(_ checks: [DoctorCheck]) -> String { public static func format(_ checks: [DoctorCheck]) -> String {
let width = checks.map(\.name.count).max() ?? 0 let width = checks.map(\.name.count).max() ?? 0
+33 -8
View File
@@ -385,18 +385,43 @@ 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 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. to click Allow) cannot reach the guest's NAT address, so VMs boot but SSH never connects.
Grant it interactively the first time — run `gitea-macos-runner vm boot` from a **On a CI host, use the subnet allowlist.** It is the only deterministic option — no prompt, no GUI
Terminal in the GUI session and click **Allow** — or pre-authorize the VM subnet: session, and nothing to redo after a rebuild:
```sh ```sh
sudo defaults write com.apple.network.local-network \ sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.0.0/16" AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
``` ```
Adjust the range to match the subnet Virtualization.framework's NAT hands out on your host (check Then **reboot** — these are read at boot, so restarting the service alone is not enough. Adjust the
`/var/db/dhcpd_leases` after a VM boots). Reboot, or restart the service, for the change to take range to match the subnet Virtualization.framework's NAT hands out on your host (check
effect. Also confirm the runner is enabled under **System Settings → Privacy & Security → Local `/var/db/dhcpd_leases` after a VM boots); if you would rather not pin it, the RFC 1918 set
Network**. `"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 can see an allowlist. 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.
**Approving interactively instead.** The app cannot be pre-approved: it appears under **System
Settings → Privacy & Security → Local Network** only *after* it has actually attempted a connection
to a guest. An empty list is expected on a fresh install and does not mean anything is broken. To
create the entry and answer the prompt, run one boot by hand from a Terminal in the GUI session:
```sh
gitea-macos-runner vm boot --image default
```
and click **Allow**. Do not wait for the LaunchAgent to hit it — a background agent has no way to
answer the prompt.
> **Caveat with ad-hoc signing.** An interactive grant is not durable for this project's ad-hoc
> signed bundle. 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 is liable to present as a new app that must be
> approved again — and macOS offers no way to reset a Local Network decision back to undetermined,
> so the stale entries accumulate. This is why the allowlist above, which is keyed on the subnet
> rather than on the app, is the recommendation for an unattended machine.
--- ---
@@ -428,7 +453,7 @@ The checks, in order:
| `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on | | `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on |
| `runner download url` | The `gitea-runner` release asset is reachable | | `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 | | `token file permissions` | Warns — not fails — when a token file is group- or world-readable |
| `local network access` | An informational note about the macOS 15+ Local Network prompt | | `local network access` | Passes when a subnet allowlist is set; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
If the config file is missing or invalid, the host checks still run and the rest 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 are skipped — which is exactly the state a first-time operator is in. Resolve
+61 -12
View File
@@ -24,6 +24,7 @@ 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/…` | | 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 | | `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 | | 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; it cannot be pre-approved | Boot one VM by hand from a GUI Terminal to create the entry, or (better on CI) allowlist the subnet with `defaults write com.apple.network.local-network` |
| 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 | | 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 |
| `SecKeyCreateRandomKey` / "Interaction is not allowed" | `login.keychain` is locked — no GUI session | Run as a LaunchAgent in an unlocked GUI session; enable auto-login | | `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 | | 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 |
@@ -96,23 +97,71 @@ 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 An entry with a recent `lease` timestamp and the guest's MAC means networking is fine and the
problem is timing — raise `scheduler.bootTimeoutSeconds`. problem is timing — raise `scheduler.bootTimeoutSeconds`.
2. No entry at all: check **System Settings → Privacy & Security → Local Network** and enable the 2. No entry at all: pre-authorize the VM subnet, then **reboot** (these are read at boot):
runner. If it isn't listed, trigger the prompt interactively from a Terminal in the GUI session:
```sh
gitea-macos-runner vm boot
```
and click **Allow**.
3. Or pre-authorize the VM subnet, then reboot:
```sh ```sh
sudo defaults write com.apple.network.local-network \ sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.0.0/16" AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
``` ```
Match the range to what your host's NAT actually hands out. Match the range to what your host's NAT actually hands out. `doctor` reports `local network
access` as a pass once it can see this. 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.
3. Or grant it interactively: run `gitea-macos-runner vm boot --image default` from a Terminal in
the GUI session and click **Allow**. The app is not listed under **System Settings → Privacy &
Security → Local Network** until it has made that first attempt.
---
## Local Network: the app is not listed in System Settings
**Symptom.** `doctor` prints the `local network access` note telling you to approve the app under
**System Settings → Privacy & Security → Local Network**, but the runner is nowhere in that list —
so there is nothing to switch on.
**Cause.** This is expected, not a bug. The Local Network list is populated lazily: an app appears
there only *after* it has actually attempted a local-network connection and been evaluated. It
cannot be pre-approved. A freshly installed runner that has not yet reached a guest has no entry.
There is also nothing to query, so `doctor` cannot tell you the grant's state. Unlike most macOS
privacy controls, Local Network privacy is not stored in TCC — per
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
the checks live "deep in the networking stack" as a Network Extension packet filter, so the
permission is absent from `TCC.db` and `tccutil reset` does not apply to it.
**Fix — interactive.** Make the app connect once, from a GUI session where a human can answer:
```sh
gitea-macos-runner vm boot --image default
```
Click **Allow**. The entry now exists and can be toggled later. Do not wait for the LaunchAgent to
trigger it; a background agent cannot answer the prompt, so it simply fails to reach the guest.
**Fix — deterministic, and what to use on a CI box.** 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:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
```
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.
> **Why the interactive grant does not stick here.** This project ships an **ad-hoc signed** bundle
> (`codesign --sign -`), and TN3179 notes that "local network privacy uses your main executable UUID
> as part of its implementation". The linker writes a new `LC_UUID` on essentially every rebuild, so
> a rebuilt-and-reinstalled runner can read as a *different* program and prompt again — while the
> old row lingers, since macOS provides no way to reset a Local Network decision to undetermined.
> Expect duplicate entries after a few upgrades. The subnet allowlist avoids all of this.
--- ---