Compare commits

...
24 Commits
Author SHA1 Message Date
abkslm b682cfd0ba Merge nucleic/vivid-glass-urchin-xoym into main
build / build (push) Successful in 2m30s
2026-08-08 17:39:40 -07:00
abkslm de3fc45777 Merge nucleic/vivid-glass-urchin-xoym into main 2026-08-08 17:19:44 -07:00
abkslm 26739f9487 Merge nucleic/vivid-glass-urchin-xoym into main 2026-08-07 16:38:56 -07:00
abkslm 902bea5091 Merge nucleic/vivid-glass-urchin-xoym into main 2026-08-07 15:41:01 -07:00
abkslm 6b0c01b74b Merge nucleic/upbeat-opal-viper-cc8l into main
build / build (push) Canceled after 0s
2026-08-07 05:11:01 -07:00
abkslm ee19744496 update README.md
build / build (push) Successful in 2m32s
2026-08-07 04:37:01 -07:00
abkslm af0369d443 Merge nucleic/mellow-dewy-falcon-rjhr into main
build / build (push) Canceled after 0s
2026-08-07 04:14:34 -07:00
abkslm 042bd813a8 Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 03:46:54 -07:00
abkslm b84835649c Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 03:30:45 -07:00
abkslm c71a457bcf Nucleic: Gitea Runner macOS VM Support 2026-08-07 03:30:44 -07:00
abkslm adb7dedd80 Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 03:12:18 -07:00
abkslm bddb120a56 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 03:12:18 -07:00
abkslm 748bc7e5e5 Nucleic: Gitea Runner macOS VM Support 2026-08-07 03:12:18 -07:00
abkslm e7163de22f Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 02:30:26 -07:00
abkslm 3b4f631dd8 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 02:30:26 -07:00
abkslm b2f15883d8 Nucleic: Gitea Runner macOS VM Support 2026-08-07 02:30:26 -07:00
abkslm d64b3f3e9b Merge nucleic/mellow-dewy-falcon-rjhr into main 2026-08-07 01:40:57 -07:00
abkslm ab4ed9c8a1 Nucleic: Gitea Runner macOS VM Support 2026-08-07 01:40:57 -07:00
abkslm 697a4cdcd0 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 01:14:28 -07:00
abkslm 975cf8c6ea Nucleic: Gitea Runner macOS VM Support 2026-08-07 01:14:28 -07:00
abkslm 40697b8329 Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 01:02:32 -07:00
abkslm 9edaa3e409 Nucleic: Gitea Runner macOS VM Support 2026-08-07 01:02:32 -07:00
abkslm 11019d498b Merge branch 'main' into nucleic/mellow-dewy-falcon-rjhr 2026-08-07 00:44:36 -07:00
abkslm 33f299396a Nucleic: Gitea Runner macOS VM Support 2026-08-07 00:44:36 -07:00
30 changed files with 3922 additions and 283 deletions
+77
View File
@@ -0,0 +1,77 @@
# Builds gitea-macos-runner on the macOS runners gitea-macos-runner itself
# provides. The repo is its own integration test: if this workflow goes green,
# a guest image really can check out a repo, run a Swift toolchain, and produce
# a signed .app.
name: build
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build:
# The bare label the daemon registers with. There is no container image
# here: jobs run in `:host` mode, directly in an ephemeral macOS 27 guest
# as the admin account, and the guest is destroyed afterwards.
runs-on: macos-arm64
# Well under the scheduler's 120-minute job ceiling. A clean release build
# plus the test suite is a few minutes; anything approaching an hour means
# something is wedged and the VM should be reclaimed rather than left
# holding one of the host's two guest slots.
timeout-minutes: 60
steps:
# Needs Node.js in the guest, which the base image provisions.
- uses: actions/checkout@v4
# First thing in the log, deliberately. Every plausible failure of this
# workflow that is not the code's fault is a toolchain that did not make
# it into the image — Command Line Tools missing, or present but not
# selected. Printing this up front turns "swift: command not found"
# forty lines down into a one-glance diagnosis.
- name: toolchain versions
run: |
sw_vers
swift --version
xcode-select -p
# RunnerCoreTests only. RunnerHost is compiled (it is a dependency) but
# never exercised: nothing here touches Virtualization.framework at
# runtime, which matters because the guest cannot nest VMs.
- name: unit tests
run: swift test
# Release build, .app assembly, signature. `make sign` looks for a
# Developer ID identity and falls back to ad-hoc when there is none — which
# is always the case here, since this runs in a throwaway guest with no
# keychain and no certificate. That fallback is why this step works
# unattended, and it is deliberately not treated as a failure: the point of
# the CI signature is to prove the entitlement survives, not to produce a
# distributable artifact. Release builds are signed on a real host.
- name: build and sign the app bundle
run: make all
# Proves the two things a bare `swift build` cannot: that the entitlement
# survived signing, and that the runtime resources were copied in. An
# .app missing either compiles perfectly and then fails at the first
# `image build` — exactly the class of breakage worth catching in CI.
- name: verify the bundle
# `grep … && echo` would be a check that cannot fail: bash exempts
# every command in an `&&` list except the last one from `set -e`, so a
# bundle signed with no entitlements at all would sail through. The
# grep therefore stands alone, as the step's own pass/fail.
run: |
codesign -d --entitlements - --xml .build/GiteaMacosRunner.app | grep -q virtualization
echo "entitlement OK"
ls .build/GiteaMacosRunner.app/Contents/Resources/
# Deliberately absent: `doctor`, `vm`, `daemon`, and `image` steps.
#
# All four either start a VM or check the host's ability to start one, and this
# job is already running inside a guest. Virtualization.framework does not
# nest, so those steps would not be a stricter test — they would be a
# guaranteed failure that says nothing about the code. The host-side behaviour
# they cover is verified on a real host, not here.
+52 -5
View File
@@ -3,7 +3,8 @@
# Virtualization.framework refuses to start a VM unless the calling process # Virtualization.framework refuses to start a VM unless the calling process
# carries the `com.apple.security.virtualization` entitlement, and entitlements # carries the `com.apple.security.virtualization` entitlement, and entitlements
# only survive on a signed bundle. So the shipping artifact is not a bare # only survive on a signed bundle. So the shipping artifact is not a bare
# executable but a minimal `.app` bundle that we ad-hoc sign. See docs/DESIGN.md # executable but a minimal `.app` bundle that we sign -- with a Developer ID
# certificate when one is in the keychain, ad-hoc otherwise. See docs/DESIGN.md
# ("Verified Facts", item 10). # ("Verified Facts", item 10).
SHELL := /bin/bash SHELL := /bin/bash
@@ -18,8 +19,9 @@ INFO_PLIST := Resources/Info.plist
# The entitlements plist grants exactly one entitlement, # The entitlements plist grants exactly one entitlement,
# `com.apple.security.virtualization`. Virtualization.framework refuses to # `com.apple.security.virtualization`. Virtualization.framework refuses to
# create a VM without it, and it is granted by ad-hoc signing # create a VM without it. It is not a restricted entitlement: ad-hoc signing
# (`codesign --sign -`) -- no Apple developer account required. # (`codesign --sign -`) grants it, and a Developer ID certificate grants it
# without a provisioning profile.
# #
# Deliberately absent: com.apple.vm.networking, which would be needed for a # Deliberately absent: com.apple.vm.networking, which would be needed for a
# bridged network attachment. That one IS restricted and requires an approved # bridged network attachment. That one IS restricted and requires an approved
@@ -43,6 +45,31 @@ APP_RESOURCES := Resources/provision.sh \
INSTALL_DIR := $(HOME)/Applications INSTALL_DIR := $(HOME)/Applications
LINK_PATH := /usr/local/bin/$(BIN_NAME) LINK_PATH := /usr/local/bin/$(BIN_NAME)
# Code signing identity.
#
# A real Developer ID certificate is what makes the bundle's code identity
# *stable across rebuilds*. Its designated requirement is anchored to the team
# ("... and certificate leaf[subject.OU] = L7UDTQ6F5W"), so macOS recognises
# every subsequent build as the same program. An ad-hoc signature has no such
# anchor, so the system falls back to the main executable's Mach-O UUID -- which
# the linker regenerates on essentially every link. Each `make install` then
# presents a program macOS has never seen, and per TN3179 that silently
# withdraws the app's Local Network grant. See docs/troubleshooting.md.
#
# TEAM_ID picks the certificate out of the keychain. When no matching
# "Developer ID Application" identity is present the build still succeeds --
# ad-hoc, with a warning -- because CI runs `make all` inside a throwaway guest
# that has neither a keychain nor a certificate, and that path must keep
# working. Override with `make sign TEAM_ID=...`, or `TEAM_ID=` to force ad-hoc.
TEAM_ID ?= L7UDTQ6F5W
# Hardened runtime plus a trusted timestamp: the pair notarization requires.
# Neither costs anything at runtime here, and having them means the bundle can
# be notarized later without re-signing. `--timestamp` contacts Apple's
# timestamp authority, so signing needs network access. Both are rejected by an
# ad-hoc signature, hence they are only passed on the Developer ID path.
SIGN_OPTS ?= --options runtime --timestamp
# Release by default; `make dev` overrides to debug. # Release by default; `make dev` overrides to debug.
CONFIG ?= release CONFIG ?= release
BIN_PATH = $(BUILD_DIR)/$(CONFIG)/$(BIN_NAME) BIN_PATH = $(BUILD_DIR)/$(CONFIG)/$(BIN_NAME)
@@ -72,11 +99,31 @@ bundle:
cp $(APP_RESOURCES) "$(RES_DIR)/" cp $(APP_RESOURCES) "$(RES_DIR)/"
chmod +x "$(RES_DIR)/provision.sh" chmod +x "$(RES_DIR)/provision.sh"
## sign: ad-hoc sign the bundle with the virtualization entitlement ## sign: sign the bundle (Developer ID when available, else ad-hoc) with the virtualization entitlement
sign: sign:
codesign --sign - --entitlements "$(ENTITLEMENTS)" --force "$(APP_DIR)" @identity=$$(security find-identity -v -p codesigning 2>/dev/null \
| grep "Developer ID Application" | grep -F "($(TEAM_ID))" \
| head -1 | awk '{print $$2}'); \
if [ -n "$$identity" ]; then \
echo "signing with Developer ID $$identity (team $(TEAM_ID))"; \
codesign --sign "$$identity" $(SIGN_OPTS) \
--entitlements "$(ENTITLEMENTS)" --force "$(APP_DIR)"; \
else \
echo "warning: no 'Developer ID Application' identity for team '$(TEAM_ID)' in the keychain."; \
echo " Falling back to an ad-hoc signature. The bundle runs and the entitlement"; \
echo " works, but its code identity changes on every rebuild, so a macOS Local"; \
echo " Network grant will not survive the next 'make install'."; \
echo " See docs/troubleshooting.md."; \
codesign --sign - --entitlements "$(ENTITLEMENTS)" --force "$(APP_DIR)"; \
fi
@echo "--- entitlements ---" @echo "--- entitlements ---"
@codesign -d --entitlements - "$(APP_DIR)" 2>/dev/null || true @codesign -d --entitlements - "$(APP_DIR)" 2>/dev/null || true
@echo "--- identity ---"
@# -dvv, not -dv: the Authority chain is only printed at the second -v.
@# The CodeDirectory line is where `flags=0x10000(runtime)` shows up, which
@# is the only proof the hardened runtime actually landed.
@codesign -dvv "$(APP_DIR)" 2>&1 \
| grep -E "^(Identifier|TeamIdentifier|Authority|Timestamp|CodeDirectory)" || true
## dev: debug build + bundle + sign (fast iteration loop) ## dev: debug build + bundle + sign (fast iteration loop)
dev: dev:
+33 -7
View File
@@ -1,4 +1,4 @@
# gitea-macos-runner # gitea-macos-vm-orchestrator
A Swift daemon that gives a self-hosted Gitea instance on-demand macOS CI capacity from a single A Swift daemon that gives a self-hosted Gitea instance on-demand macOS CI capacity from a single
Apple Silicon Mac. It polls Gitea for queued Actions jobs that request macOS, boots a fresh Apple Silicon Mac. It polls Gitea for queued Actions jobs that request macOS, boots a fresh
@@ -51,17 +51,20 @@ were killed uncleanly, so the Gitea runner list does not accumulate dead entries
- **Gitea 1.25 or newer** (1.26+ recommended). 1.25 added the admin jobs API with the `labels` - **Gitea 1.25 or newer** (1.26+ recommended). 1.25 added the admin jobs API with the `labels`
field this daemon depends on. field this daemon depends on.
- A code-signed app bundle. The binary must carry the `com.apple.security.virtualization` - A code-signed app bundle. The binary must carry the `com.apple.security.virtualization`
entitlement; ad-hoc signing (`codesign -s -`) is sufficient, so no paid Apple developer account entitlement, which is not a restricted entitlement — ad-hoc signing (`codesign -s -`) grants it,
is required. so the runner *works* with no Apple developer account. A **Developer ID Application** certificate
is nonetheless recommended: it anchors the bundle's code identity to your team, which is what
keeps a macOS Local Network grant alive across rebuilds. See
[Code signing](docs/setup.md#code-signing).
## Quickstart ## Quickstart
```sh ```sh
git clone <this repo> && cd gitea-macos-runner git clone <this repo> && cd gitea-macos-runner
# Build, bundle (binary + Resources + Info.plist), ad-hoc sign with the # Build, bundle (binary + Resources + Info.plist), sign with the virtualization
# virtualization entitlement, then copy to ~/Applications and symlink the CLI # entitlement (Developer ID if a matching certificate is in the keychain, ad-hoc
# into /usr/local/bin. # otherwise), then copy to ~/Applications and symlink the CLI into /usr/local/bin.
make install # = make build bundle sign, then the install step make install # = make build bundle sign, then the install step
# Write a starter config to ~/.config/gitea-macos-runner/config.json # Write a starter config to ~/.config/gitea-macos-runner/config.json
@@ -84,6 +87,8 @@ gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_*.ipsw
gitea-macos-runner image provision default --xcode-xip ~/Downloads/Xcode_XX.xip gitea-macos-runner image provision default --xcode-xip ~/Downloads/Xcode_XX.xip
# Install and start the LaunchAgent (runs in your GUI login session — not a LaunchDaemon). # Install and start the LaunchAgent (runs in your GUI login session — not a LaunchDaemon).
# On a terminal this also offers to grant macOS Local Network access, which the agent
# needs to reach its guests; `permissions grant` does the same thing on its own.
gitea-macos-runner service install gitea-macos-runner service install
gitea-macos-runner service status gitea-macos-runner service status
``` ```
@@ -105,6 +110,25 @@ jobs:
The daemon picks the job up within one poll interval, boots a VM, and tears it down when the job The daemon picks the job up within one poll interval, boots a VM, and tears it down when the job
finishes. finishes.
## CI
This repo builds itself. [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) runs on
`macos-arm64` — the very runners this project provides — and does a full `swift test`, `make all`,
and a check that the resulting `.app` carries the virtualization entitlement and its runtime
resources. A green run is also an end-to-end test of the runner: it means a guest image really can
check out a repo, run the Swift toolchain, and produce a signed bundle.
For it to run at all you need:
- the repo pushed to a Gitea **1.25 or newer** instance with Actions enabled
(`[actions] ENABLED = true`),
- `gitea-macos-runner daemon` running on an Apple silicon host and registered with that instance,
- a base image built and provisioned (`image build`) — `image list` should show `PROVISIONED: yes`.
The workflow deliberately runs no `doctor`, `vm`, `daemon`, or `image` steps. Those start a VM or
probe the host's ability to start one, and the job is already inside a guest; Virtualization
does not nest, so they would fail for reasons that say nothing about the code.
## Documentation ## Documentation
- [docs/setup.md](docs/setup.md) — full Gitea-side and host-side walkthrough, config reference, - [docs/setup.md](docs/setup.md) — full Gitea-side and host-side walkthrough, config reference,
@@ -126,9 +150,11 @@ Every subcommand accepts the global options `--config PATH` (`-c`, default
| `image delete NAME [--force]` | Delete a base image and its disk. `--force` (`-f`) skips the confirmation prompt. | | `image delete NAME [--force]` | Delete a base image and its disk. `--force` (`-f`) skips the confirmation prompt. |
| `vm boot [--image NAME] [--slot N] [--keep]` | Clone an image, boot it, print its IP, and wait for Ctrl-C. `--slot` picks which persistent per-slot MAC to use (default `0`); `--keep` leaves the clone on disk. | | `vm boot [--image NAME] [--slot N] [--keep]` | Clone an image, boot it, print its IP, and wait for Ctrl-C. `--slot` picks which persistent per-slot MAC to use (default `0`); `--keep` leaves the clone on disk. |
| `vm list` | List ephemeral VM clones on disk. | | `vm list` | List ephemeral VM clones on disk. |
| `service install [--executable PATH]` | Write and load `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist`. | | `service install [--executable PATH] [--grant-local-network allowlist\|prompt\|none]` | Write and load `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist`. Also evicts any agent left behind under a previous label. On a terminal it offers to configure Local Network access when that is unconfigured, defaulting to no; `--grant-local-network` decides it up front. |
| `service uninstall` | Unload the LaunchAgent and remove its plist. | | `service uninstall` | Unload the LaunchAgent and remove its plist. |
| `service status` | Report LaunchAgent installation and run state. | | `service status` | Report LaunchAgent installation and run state. |
| `permissions status` | Report whether macOS Local Network access is configured, and whether the code identity is stable enough to hold an interactive grant. Run it under `sudo` to see the allowlist — it is written into root's preferences, which an ordinary login cannot read. |
| `permissions grant [--method allowlist\|prompt] [--subnet CIDR ...] [--reboot\|--no-reboot]` | Grant Local Network access. `allowlist` (default) writes the subnet allowlist with sudo — all of RFC 1918 unless `--subnet` narrows it — and needs a reboot. `prompt` launches the installed `.app` so the system alert is attributed to it rather than to Terminal, and applies immediately. |
| `doctor [--json] [--no-fail]` | Preflight checks. `--json` emits machine-readable results; `--no-fail` exits zero even when checks fail. | | `doctor [--json] [--no-fail]` | Preflight checks. `--json` emits machine-readable results; `--no-fail` exits zero even when checks fail. |
| `config init [--force] [--instance-url URL]` | Write the annotated example config. `--force` (`-f`) overwrites an existing file. | | `config init [--force] [--instance-url URL]` | Write the annotated example config. `--force` (`-f`) overwrites an existing file. |
| `config show` | Print the effective configuration with secrets redacted. | | `config show` | Print the effective configuration with secrets redacted. |
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>xyz.blakeslee.gitea-macos-runner</string> <string>xyz.blakeslee.gitea-macos-vm-orchestrator</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>GiteaMacosRunner</string> <string>GiteaMacosRunner</string>
+10 -2
View File
@@ -124,8 +124,16 @@ fi
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
log "ensuring /usr/local/bin exists and is on PATH for non-login shells" log "ensuring /usr/local/bin exists and is on PATH for non-login shells"
mkdir -p /usr/local/bin mkdir -p /usr/local/bin
chown root:wheel /usr/local /usr/local/bin # Best-effort, deliberately: on a stock macOS 27 install /usr/local already
chmod 755 /usr/local /usr/local/bin # exists, already is root:wheel 755, and is SIP-protected — so chown and chmod
# on it fail with "Operation not permitted" even as root. The directory is
# already exactly as we want it, so treating that refusal as a build failure
# would abort provisioning over a no-op. When /usr/local really was ours to
# create, these succeed.
chown root:wheel /usr/local /usr/local/bin 2>/dev/null \
|| warn "could not chown /usr/local (system-owned and SIP-protected; already correct)"
chmod 755 /usr/local /usr/local/bin 2>/dev/null \
|| warn "could not chmod /usr/local (system-owned and SIP-protected; already correct)"
ZSHENV_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH" ZSHENV_MARKER="# gitea-macos-runner: ensure /usr/local/bin on PATH"
if [ ! -f /etc/zshenv ] || ! grep -qF "$ZSHENV_MARKER" /etc/zshenv 2>/dev/null; then if [ ! -f /etc/zshenv ] || ! grep -qF "$ZSHENV_MARKER" /etc/zshenv 2>/dev/null; then
+14 -1
View File
@@ -140,6 +140,19 @@ public struct RunnerConfig: Codable, Sendable, Equatable {
/// Ceiling on boot + DHCP lease + SSH readiness before a slot is /// Ceiling on boot + DHCP lease + SSH readiness before a slot is
/// declared dead and recycled. /// declared dead and recycled.
///
/// - Important: This must exceed the guest's *worst-case* time to become
/// SSH-ready, not its typical one. A clone that is still booting when
/// this expires is destroyed and replaced by another clone that starts
/// from zero — and because the replacement adds load to an already
/// contended host, the next boot is slower still. Set too tight, this
/// is not a timeout but a livelock: the daemon boots forever and no
/// runner ever registers.
///
/// A guest sharing an Apple Silicon host with other Virtualization
/// guests can take several minutes to reach `sshd`, so the default is
/// deliberately generous. A genuinely wedged guest still gets caught;
/// it just takes longer to notice, which is the cheaper mistake.
public var bootTimeoutSeconds: Int public var bootTimeoutSeconds: Int
public init( public init(
@@ -147,7 +160,7 @@ public struct RunnerConfig: Codable, Sendable, Equatable {
pollIntervalSeconds: Int = 5, pollIntervalSeconds: Int = 5,
reconcileIntervalSeconds: Int = 300, reconcileIntervalSeconds: Int = 300,
jobTimeoutMinutes: Int = 120, jobTimeoutMinutes: Int = 120,
bootTimeoutSeconds: Int = 300 bootTimeoutSeconds: Int = 900
) { ) {
self.maxConcurrentVMs = maxConcurrentVMs self.maxConcurrentVMs = maxConcurrentVMs
self.pollIntervalSeconds = pollIntervalSeconds self.pollIntervalSeconds = pollIntervalSeconds
+280
View File
@@ -0,0 +1,280 @@
import Foundation
/// The macOS 15+ Local Network subnet allowlist: where it lives, what counts as
/// covering the guest range, and the commands that write it.
///
/// ## Why an allowlist at all
///
/// Local Network privacy is not TCC. It is a Network Extension packet filter,
/// so there is no database to query, `tccutil` does not apply, and a blocked
/// flow is not reported as "denied" — it comes back `EHOSTUNREACH` (errno 65,
/// "No route to host"), indistinguishable from a guest that is genuinely off
/// the network (Apple, TN3179).
///
/// Worse, nothing here is well placed to *answer* the prompt. A LaunchAgent has
/// no UI to show it in, and a run started from a shell is attributed to the
/// **responsible process** — Terminal — so both the prompt and the System
/// Settings row belong to Terminal, and granting it there does not carry over
/// to the agent.
///
/// The allowlist sidesteps all of that: it is consulted before the per-app
/// check, so a flow to a listed subnet is never subject to a prompt, by any
/// process. Its one cost is that the values are read at boot, so setting it
/// requires a reboot to take effect. That is the trade this type exists to make
/// explicit.
///
/// Everything here is pure — reading a plist and formatting argument vectors —
/// so it lives in `RunnerCore` and is unit-tested. The effectful half (running
/// `sudo`, launching the app to trigger a prompt) is `RunnerHost`'s
/// `LocalNetworkPermission`.
public enum LocalNetworkPolicy {
// MARK: - Where the setting lives
/// The preferences domain macOS reads the allowlist from.
public static let domain = "com.apple.network.local-network"
/// The wired interfaces key.
public static let ethernetKey = "AllowedEthernetLocalNetworkAddresses"
/// The Wi-Fi interfaces key.
public static let wifiKey = "AllowedWiFiLocalNetworkAddresses"
/// Both keys. Guests are reached over a virtual interface, and which of the
/// two the filter consults is not something we get to observe — so both are
/// always written, and both are read back.
public static let keys = [ethernetKey, wifiKey]
/// Every preferences file the allowlist could plausibly be written to.
///
/// The domain is written with `sudo`, so which preferences directory it
/// lands in depends on whether that `sudo` preserved `HOME`. Rather than
/// guess at the host's sudoers configuration, check each candidate.
///
/// In practice the first candidate is where it lands, and `/var/root` is
/// mode 700 — so an unprivileged process cannot read back what it just
/// wrote. That is what ``Status/unreadablePaths`` exists to report, and
/// what `RunnerHost`'s `LocalNetworkPermission.observedStatus()` works
/// around by re-reading as root.
public static func preferenceCandidates() -> [String] {
[
"/var/root/Library/Preferences/\(domain).plist",
"/Library/Preferences/\(domain).plist",
NSHomeDirectory() + "/Library/Preferences/\(domain).plist",
]
}
// MARK: - What to write
/// The subnets granted by default: all of RFC 1918.
///
/// Deliberately wider than the `192.168.64.0/18` that vmnet actually uses.
/// The allowlist is read at boot, so getting it wrong costs a reboot to fix,
/// and the failure mode of "too narrow" is silent — guests simply stop being
/// reachable the day the host joins a network that shifts things around.
/// This is also the set Tart, orchard, and packer-plugin-tart ship, so a
/// host already configured for one of those needs no second entry.
///
/// Narrow it with `permissions grant --subnet` on a host where that matters.
public static let defaultSubnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
/// The argument vectors that write `subnets` to both keys.
///
/// Returned as arrays, not a shell string: these are handed straight to
/// `/usr/bin/defaults` with no shell in between, so a subnet containing
/// something shell-significant cannot become an injection.
///
/// - Parameter subnets: CIDR entries, e.g. `["192.168.0.0/16"]`.
/// - Returns: One `defaults write …` argument vector per key.
public static func writeArguments(subnets: [String]) -> [[String]] {
keys.map { key in ["write", domain, key, "-array"] + subnets }
}
/// The same commands as copy-pasteable shell, for the message shown when we
/// cannot run them ourselves.
public static func writeCommandLines(subnets: [String]) -> [String] {
writeArguments(subnets: subnets).map { arguments in
"sudo defaults " + arguments.map(quoteForShell).joined(separator: " ")
}
}
/// Single-quotes an argument unless it is plainly inert.
private static func quoteForShell(_ argument: String) -> String {
let safe = argument.allSatisfy { $0.isLetter || $0.isNumber || "./_-".contains($0) }
guard !safe || argument.isEmpty else { return argument }
return "'" + argument.replacingOccurrences(of: "'", with: #"'\''"#) + "'"
}
// MARK: - Reading it back
/// What the host's allowlist currently says.
public struct Status: Sendable, Equatable {
/// Every distinct entry found, across both keys and all candidate files.
public let allowlist: [String]
/// The files the entries came from, in the order they were checked.
public let sourcePaths: [String]
/// Whether at least one entry covers the whole vmnet range.
public let coversGuestRange: Bool
/// Candidate files this process was refused permission to read.
///
/// Not the same as "absent". `sudo defaults write` normally lands in
/// `/var/root/Library/Preferences`, which is mode 700, so an ordinary
/// user is refused before it can find out whether the file is even
/// there. An empty ``allowlist`` with a non-empty `unreadablePaths`
/// means *unknown*, not *unconfigured*, and must not be reported as
/// the latter.
public let unreadablePaths: [String]
/// Whether anything is configured at all.
public var isConfigured: Bool { !allowlist.isEmpty }
/// Whether nothing was found and something could not be read, so the
/// answer is genuinely unknown without administrator rights.
public var isIndeterminate: Bool { allowlist.isEmpty && !unreadablePaths.isEmpty }
public init(
allowlist: [String],
sourcePaths: [String],
coversGuestRange: Bool,
unreadablePaths: [String] = []
) {
self.allowlist = allowlist
self.sourcePaths = sourcePaths
self.coversGuestRange = coversGuestRange
self.unreadablePaths = unreadablePaths
}
}
/// Reads the host's current allowlist with this process's own privileges.
///
/// Best effort and never fatal: an absent preferences file reads as "no
/// allowlist", and one that exists but cannot be opened is recorded in
/// ``Status/unreadablePaths`` rather than being mistaken for absent.
public static func status() -> Status {
var sources: [(path: String, data: Data)] = []
var unreadable: [String] = []
for path in preferenceCandidates() {
if let data = FileManager.default.contents(atPath: path) {
sources.append((path, data))
} else if access(path, R_OK) != 0, errno == EACCES {
// Refused, not missing — including when the refusal is on a
// parent directory, which is exactly the /var/root case.
unreadable.append(path)
}
}
return status(fromContentsOf: sources, unreadablePaths: unreadable)
}
/// Builds a ``Status`` from preferences files already read, however they
/// were obtained.
///
/// Split out from ``status()`` so the privileged read-back in `RunnerHost`
/// — which has to shell out to `sudo` to see root's copy — shares this
/// parsing rather than reimplementing it.
public static func status(
fromContentsOf sources: [(path: String, data: Data)],
unreadablePaths: [String] = []
) -> Status {
var found: [String] = []
var paths: [String] = []
for source in sources {
let fresh = entries(inPreferences: source.data).filter { !found.contains($0) }
guard !fresh.isEmpty else { continue }
found.append(contentsOf: fresh)
paths.append(source.path)
}
return Status(
allowlist: found,
sourcePaths: paths,
coversGuestRange: found.contains(where: coversVMNetRange),
unreadablePaths: unreadablePaths
)
}
/// Every allowlist entry in one preferences file, across both keys, in the
/// order encountered and without duplicates. Unparseable data reads empty.
public static func entries(inPreferences data: Data) -> [String] {
guard
let plist = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil) as? [String: Any]
else { return [] }
var found: [String] = []
for key in keys {
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
found.append(entry)
}
}
return found
}
/// Subnets pre-authorized for local network access on this host, if any.
public static func allowlist() -> [String] { status().allowlist }
// MARK: - Coverage arithmetic
/// The span of addresses a vmnet NAT link can plausibly use.
///
/// `192.168.64.0/24` is only the *first* choice: the subnet is picked at
/// runtime and steps to the next free /24 when that one is already in use,
/// which is why a host that worked yesterday can hand out `192.168.65.x`
/// today. Everything from 192.168.64.0 to 192.168.127.255 — a /18 — is
/// treated as guest territory so the allowlist survives that drift.
public static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0
public static let vmNetLastAddress: UInt32 = 0xC0A8_7FFF // 192.168.127.255
/// Whether one allowlist entry covers the whole guest range.
///
/// Deliberately all-or-nothing: partial cover is the failure mode being
/// warned about, so an entry that contains today's subnet but not
/// tomorrow's is not treated as good enough.
public static func coversVMNetRange(_ entry: String) -> Bool {
guard let (network, broadcast) = range(of: entry) else { return false }
return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress
}
/// Whether `entry` is a well-formed IPv4 address or CIDR block.
///
/// Used to reject `--subnet` typos at parse time. An entry macOS cannot
/// understand is silently ignored by the filter, which would leave the
/// operator with a configured-looking allowlist that grants nothing.
public static func isValidSubnet(_ entry: String) -> Bool { range(of: entry) != nil }
/// The first and last address of an IPv4 CIDR entry; nil for anything that
/// is not one (an IPv6 entry, a hostname, a typo).
///
/// A bare address is treated as a /32, matching `defaults`' own reading.
static func range(of entry: String) -> (network: UInt32, broadcast: UInt32)? {
// Empty components are kept, so a trailing slash is a parse failure
// rather than silently reading "192.168.64.0/" as a bare /32 host.
let parts = entry.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
guard let first = parts.first, let base = ipv4Value(String(first)) else { return nil }
let prefix = parts.count == 2 ? Int(parts[1]) : 32
guard let prefix, (0...32).contains(prefix) else { return nil }
let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix)
let network = base & mask
return (network, network | ~mask)
}
/// Packs dotted-quad IPv4 into a comparable integer; nil for anything else
/// (an IPv6 entry, a hostname, a typo).
public static func ipv4Value(_ text: String) -> UInt32? {
let octets = text.split(separator: ".", omittingEmptySubsequences: false)
guard octets.count == 4 else { return nil }
var value: UInt32 = 0
for octet in octets {
guard let number = UInt32(octet), number <= 255 else { return nil }
value = value << 8 | number
}
return value
}
}
+187
View File
@@ -0,0 +1,187 @@
import Foundation
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
/// Resolves a path typed on the command line into a single concrete path.
///
/// The problem this exists for: an operator writes
/// `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`. Unquoted, the shell may expand
/// the glob before we ever run — or, in `zsh`, refuse to run the command at all
/// with `no matches found`. Quoted, we receive the pattern verbatim, tilde and
/// asterisk included, and a plain `expandingTildeInPath` leaves a `*` sitting in
/// the middle of a path that will never exist. Either way the operator sees a
/// tool that "only works with quotes" (or only without them).
///
/// So the CLI resolves the argument itself and stops depending on which shell
/// ran it. This is deliberately a **CLI-argument affordance only**: values read
/// out of `config.json` get tilde expansion (see
/// ``RunnerConfig/expandTilde(_:)``) and nothing more, because a config file is
/// not typed at a prompt and a stray `*` there is a mistake, not a pattern.
public enum PathResolution {
/// Characters that make a string a pattern rather than a path.
///
/// `~` is not one of them: it is expanded unconditionally, pattern or not.
private static let metacharacters: Set<Character> = ["*", "?", "["]
/// Whether `path` should be treated as a glob pattern.
public static func isPattern(_ path: String) -> Bool {
path.contains(where: metacharacters.contains)
}
/// Expands a leading `~`, then resolves any glob pattern to one path.
///
/// A string with no metacharacters passes through with only tilde
/// expansion — in particular it is *not* checked for existence, so the
/// caller's own error (which knows what the file was for) is what an
/// operator sees for an ordinary typo.
///
/// A string that does contain metacharacters is matched with `glob(3)`. If
/// it matches nothing but a file exists at that literal name, the literal
/// wins: `report[1].ipsw` is a legal filename, and only failing to match
/// tells us it was meant as one rather than as a character class.
///
/// - Parameters:
/// - path: The raw argument, as typed.
/// - label: What the argument names, for error messages (`"--ipsw"`).
/// - Returns: A single concrete path.
/// - Throws: ``CoreError/notFound(_:)`` when a pattern matches nothing, or
/// ``CoreError/configInvalid(_:)`` when it matches more than one file —
/// picking one arbitrarily would silently build the wrong image.
public static func resolve(_ path: String, label: String) throws -> String {
let expanded = RunnerConfig.expandTilde(path)
guard isPattern(expanded) else { return expanded }
let matches = glob(pattern: expanded)
switch matches.count {
case 1:
return matches[0]
case 0:
if FileManager.default.fileExists(atPath: expanded) { return expanded }
throw CoreError.notFound("\(label): no file matches \(expanded)")
default:
let listed = matches.map { " \($0)" }.joined(separator: "\n")
throw CoreError.configInvalid(
"\(label): \(matches.count) files match \(expanded):\n\(listed)\n"
+ "name exactly one of them"
)
}
}
/// All paths matching `pattern`, sorted.
///
/// Sorted explicitly rather than relying on `glob(3)`'s own ordering, which
/// is locale-dependent — the error message above lists these, and a listing
/// that reorders between runs is a poor thing to ask someone to read.
static func glob(pattern: String) -> [String] {
var result = glob_t()
defer { globfree(&result) }
guard Glibc_glob(pattern, &result) == 0 else { return [] }
guard let paths = result.gl_pathv else { return [] }
var found: [String] = []
for index in 0..<Int(result.gl_pathc) {
guard let entry = paths[index] else { continue }
found.append(String(cString: entry))
}
return found.sorted()
}
/// Thin shim so the call above reads the same on both platforms; `glob(3)`
/// is otherwise shadowed by the ``glob(pattern:)`` above.
private static func Glibc_glob(
_ pattern: String,
_ result: UnsafeMutablePointer<glob_t>
) -> Int32 {
#if canImport(Darwin)
return Darwin.glob(pattern, 0, nil, result)
#elseif canImport(Glibc)
return Glibc.glob(pattern, 0, nil, result)
#else
return -1
#endif
}
}
/// Cheap sanity checks on a `.ipsw` before Virtualization.framework sees it.
///
/// Lives beside ``PathResolution`` because it is the other half of the same
/// job — what the CLI does with a path an operator typed — and because
/// `RunnerCore` is the only target that unit-tests on both platforms.
///
/// The checks earn their place: `VZMacOSRestoreImage.image(from:)` reports no
/// progress at all while it works, and on a partial download it can sit for a
/// very long time rather than failing. Without these, "I pointed it at the
/// wrong file" and "my 21 GB download stopped at 4 GB" both present to the
/// operator as an unexplained hang.
public enum IPSWFile {
/// The floor a real macOS restore image clears by an order of magnitude —
/// they run ~15-22 GB. Anything under this is a truncated download, a
/// placeholder, or the wrong file entirely.
public static let minimumBytes: Int64 = 1_000_000_000
/// The first two bytes of every `.ipsw`: an IPSW is a zip archive.
static let zipMagic = Data([0x50, 0x4B]) // "PK"
/// Fails fast if `path` cannot be a usable restore image.
///
/// - Parameters:
/// - path: An already-resolved absolute path (see ``PathResolution``).
/// - label: What the file is, for error messages.
/// - Throws: ``CoreError/notFound(_:)`` if it is not there or unreadable,
/// ``CoreError/configInvalid(_:)`` if it is there but cannot be an IPSW.
public static func validate(path: String, label: String = "restore image") throws {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else {
throw CoreError.notFound("\(label) not found at \(path)")
}
guard !isDirectory.boolValue else {
throw CoreError.configInvalid(
"\(label) at \(path) is a directory, not an .ipsw file"
)
}
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
guard size >= minimumBytes else {
throw CoreError.configInvalid(
"\(label) at \(path) is only \(describeSize(size)) — a macOS restore image is "
+ "~15-22 GB. The download is most likely incomplete; delete the file and "
+ "fetch it again."
)
}
guard let handle = FileHandle(forReadingAtPath: path) else {
throw CoreError.notFound("\(label) at \(path) could not be opened for reading")
}
defer { try? handle.close() }
let magic = (try? handle.read(upToCount: zipMagic.count)) ?? Data()
guard magic == zipMagic else {
let found = magic.map { String(format: "%02x", $0) }.joined()
throw CoreError.configInvalid(
"\(label) at \(path) does not look like an .ipsw: expected a zip archive "
+ "(magic \"PK\", 504b) but the file starts with \(found.isEmpty ? "nothing" : found). "
+ "Check the path, or re-download the file."
)
}
}
/// A byte count an operator can compare against "~15 GB" at a glance.
static func describeSize(_ bytes: Int64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var value = Double(bytes)
var unit = 0
while value >= 1024, unit < units.count - 1 {
value /= 1024
unit += 1
}
return unit == 0 ? "\(Int(value)) B" : String(format: "%.1f %@", value, units[unit])
}
}
+129 -8
View File
@@ -179,7 +179,7 @@ public final class SSHExecutor: GuestExecutor, @unchecked Sendable {
/// otherwise read from the terminal exits instead of hanging. /// otherwise read from the terminal exits instead of hanging.
/// - timeout: Wall-clock ceiling on the whole exchange. /// - timeout: Wall-clock ceiling on the whole exchange.
/// - Throws: ``SSHTransportError`` for connect/auth problems (which /// - Throws: ``SSHTransportError`` for connect/auth problems (which
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` needs /// ``waitForSSH(host:port:username:password:timeout:pollInterval:reportInterval:onAttemptFailure:)`` needs
/// to tell apart), or ``CoreError/timeout(_:)`` when the ceiling elapses. /// to tell apart), or ``CoreError/timeout(_:)`` when the ceiling elapses.
func execute(_ command: String, stdin: Data?, timeout: Duration) async throws -> SSHCommandResult { func execute(_ command: String, stdin: Data?, timeout: Duration) async throws -> SSHCommandResult {
let group = MultiThreadedEventLoopGroup.singleton let group = MultiThreadedEventLoopGroup.singleton
@@ -189,7 +189,18 @@ public final class SSHExecutor: GuestExecutor, @unchecked Sendable {
let host = self.host let host = self.host
let port = self.port let port = self.port
// The `timeout` argument below cannot bound the connect: its watchdog is
// scheduled on the channel's event loop, which does not exist until the
// connect has already succeeded. A booting guest answers ARP long before
// it answers SYNs, so without an explicit ceiling here each probe hangs
// for the platform default — around 75 s — and `waitForSSH` gets a
// handful of attempts inside its budget instead of one every couple of
// seconds. Bounded by `timeout` so a caller asking for less than the
// default gets what it asked for.
let connectTimeout = min(timeout, Self.defaultConnectTimeout)
let bootstrap = ClientBootstrap(group: group) let bootstrap = ClientBootstrap(group: group)
.connectTimeout(.nanoseconds(Self.nanoseconds(connectTimeout)))
.channelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) .channelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1)
.channelInitializer { channel in .channelInitializer { channel in
channel.eventLoop.makeCompletedFuture { channel.eventLoop.makeCompletedFuture {
@@ -288,7 +299,14 @@ public final class SSHExecutor: GuestExecutor, @unchecked Sendable {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
} }
private static func nanoseconds(_ duration: Duration) -> Int64 { /// Ceiling on the TCP connect alone.
///
/// Long enough that a loaded host's slow-but-working connect is not cut
/// short, short enough that a silently dropped SYN costs one poll interval
/// rather than the platform's ~75 s.
static let defaultConnectTimeout = Duration.seconds(10)
static func nanoseconds(_ duration: Duration) -> Int64 {
let components = duration.components let components = duration.components
let seconds = components.seconds.multipliedReportingOverflow(by: 1_000_000_000) let seconds = components.seconds.multipliedReportingOverflow(by: 1_000_000_000)
guard !seconds.overflow else { return .max } guard !seconds.overflow else { return .max }
@@ -302,7 +320,7 @@ public final class SSHExecutor: GuestExecutor, @unchecked Sendable {
// MARK: - Transport failures // MARK: - Transport failures
/// Connection-level failures, kept distinct from ``CoreError`` so that /// Connection-level failures, kept distinct from ``CoreError`` so that
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` can tell /// ``waitForSSH(host:port:username:password:timeout:pollInterval:reportInterval:onAttemptFailure:)`` can tell
/// "sshd is not up yet" (retry) from "the password is wrong" (give up now). /// "sshd is not up yet" (retry) from "the password is wrong" (give up now).
enum SSHTransportError: Error { enum SSHTransportError: Error {
/// No TCP connection could be established. /// No TCP connection could be established.
@@ -313,11 +331,49 @@ enum SSHTransportError: Error {
var asCoreError: CoreError { var asCoreError: CoreError {
switch self { switch self {
case .connectFailed(let host, let port, let underlying): case .connectFailed(let host, let port, let underlying):
return .sshFailed("cannot connect to \(host):\(port): \(underlying)") return .sshFailed(
"cannot connect to \(host):\(port): \(underlying)"
+ Self.localNetworkHint(for: underlying)
)
case .authenticationFailed(let host, let username): case .authenticationFailed(let host, let username):
return .sshFailed("authentication failed for \(username)@\(host)") return .sshFailed("authentication failed for \(username)@\(host)")
} }
} }
/// Extra guidance for the one connect failure that is usually not a network
/// problem at all.
///
/// macOS 15 and newer filter local-network traffic per app, and a blocked
/// flow is not reported as "denied": the filter answers `EHOSTUNREACH`
/// (errno 65, "No route to host"), which is exactly what a guest that is
/// genuinely off the network looks like. Guests here sit on a host-private
/// NAT link that is reachable whenever the VM is up, so on this code path
/// that errno is more often the privacy filter than a routing failure —
/// worth naming rather than leaving the operator to guess.
///
/// On an **ad-hoc signed** build it matters most right after a rebuild. Per
/// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
/// the grant "uses your main executable UUID" when there is no stable
/// designated requirement to key on, and the linker mints a fresh `LC_UUID`
/// on essentially every build — so `make install` can present a program
/// macOS has never seen, whose permission is undetermined again, even
/// though the previous binary worked minutes earlier. A Developer ID
/// signature is anchored to the team instead and does not have this
/// problem; the hint is unconditional because this layer cannot see which
/// kind of signature it is running under.
static func localNetworkHint(for underlying: any Error) -> String {
let text = "\(underlying)".lowercased()
guard text.contains("errno: 65") || text.contains("no route to host")
|| text.contains("host is unreachable")
else { return "" }
return """
(on macOS 15+ this is also what Local Network privacy returns when \
it blocks an app. Check and fix it with `gitea-macos-runner \
permissions status` / `permissions grant`. \
See docs/troubleshooting.md)
"""
}
} }
/// Shared, thread-safe record of whether the server rejected our password. /// Shared, thread-safe record of whether the server rejected our password.
@@ -531,12 +587,42 @@ final class ExecChannelHandler: ChannelInboundHandler {
} }
} }
/// One failed probe, handed to ``waitForSSH(host:port:username:password:timeout:pollInterval:reportInterval:onAttemptFailure:)``'s
/// reporting callback.
///
/// Carries a rendered `error` rather than the `Error` itself so the whole value
/// is `Sendable` and can cross into a logger on another isolation domain.
public struct SSHWaitAttempt: Sendable {
/// 1-based probe count.
public let attempt: Int
/// Time since the wait began.
public let elapsed: Duration
/// The failure, rendered through `asCoreError` where applicable so the
/// Local Network privacy hint survives.
public let error: String
public init(attempt: Int, elapsed: Duration, error: String) {
self.attempt = attempt
self.elapsed = elapsed
self.error = error
}
}
/// Blocks until a guest accepts an authenticated SSH session, or the deadline /// Blocks until a guest accepts an authenticated SSH session, or the deadline
/// passes. /// passes.
/// ///
/// Called after a DHCP lease appears but before any provisioning: a fresh guest /// Called after a DHCP lease appears but before any provisioning: a fresh guest
/// answers on port 22 only once `launchd` has started `sshd`, which lags the /// answers on port 22 only once `launchd` has started `sshd`, which lags the
/// lease by tens of seconds. /// lease by tens of seconds — and by minutes on a host running several guests
/// at once.
///
/// - Important: A caller whose own supervisor also enforces a deadline must pass
/// a `timeout` strictly smaller than the supervisor's *remaining* budget.
/// Otherwise the supervisor always fires first, this function is cancelled
/// mid-`Task.sleep`, and the `CoreError.timeout` below — the only place the
/// last error is ever rendered — is never thrown. That is why failures are
/// also reported as they happen via `onAttemptFailure` rather than solely at
/// the end.
/// ///
/// - Parameters: /// - Parameters:
/// - host: Guest IP. /// - host: Guest IP.
@@ -545,6 +631,11 @@ final class ExecChannelHandler: ChannelInboundHandler {
/// - password: Guest password. /// - password: Guest password.
/// - timeout: Overall ceiling. /// - timeout: Overall ceiling.
/// - pollInterval: Delay between attempts. Defaults to 2 s. /// - pollInterval: Delay between attempts. Defaults to 2 s.
/// - reportInterval: Floor on the gap between `onAttemptFailure` calls.
/// Defaults to 30 s. The first failure is always reported.
/// - onAttemptFailure: Called for the first failure and then no more often
/// than `reportInterval`, so a boot that is merely slow is visible while it
/// is happening instead of only in the post-mortem.
/// - Throws: ``CoreError/timeout(_:)`` if the guest never answers. /// - Throws: ``CoreError/timeout(_:)`` if the guest never answers.
public func waitForSSH( public func waitForSSH(
host: String, host: String,
@@ -552,13 +643,18 @@ public func waitForSSH(
username: String, username: String,
password: String, password: String,
timeout: Duration, timeout: Duration,
pollInterval: Duration = .seconds(2) pollInterval: Duration = .seconds(2),
reportInterval: Duration = .seconds(30),
onAttemptFailure: (@Sendable (SSHWaitAttempt) -> Void)? = nil
) async throws { ) async throws {
let executor = SSHExecutor(host: host, port: port, username: username, password: password) let executor = SSHExecutor(host: host, port: port, username: username, password: password)
let started = ContinuousClock.now let started = ContinuousClock.now
var lastError: Error? var lastError: Error?
var attempt = 0
var lastReport: ContinuousClock.Instant?
while true { while true {
attempt += 1
do { do {
// A real authenticated session running a trivial command, not a bare // A real authenticated session running a trivial command, not a bare
// TCP probe: sshd binds the port before it is ready to authenticate, // TCP probe: sshd binds the port before it is ready to authenticate,
@@ -579,11 +675,36 @@ public func waitForSSH(
lastError = error lastError = error
} }
if let onAttemptFailure, let lastError {
let now = ContinuousClock.now
// Every probe of a guest that is still booting fails, so reporting
// each one would bury the log. First one, then a heartbeat.
if lastReport.map({ now - $0 >= reportInterval }) ?? true {
lastReport = now
onAttemptFailure(
SSHWaitAttempt(
attempt: attempt,
elapsed: now - started,
error: renderSSHWaitError(lastError)
)
)
}
}
guard ContinuousClock.now - started < timeout else { break } guard ContinuousClock.now - started < timeout else { break }
try await Task.sleep(for: pollInterval) try await Task.sleep(for: pollInterval)
guard ContinuousClock.now - started < timeout else { break } guard ContinuousClock.now - started < timeout else { break }
} }
let detail = lastError.map { "; last error: \($0)" } ?? "" let detail = lastError.map { "; last error: \(renderSSHWaitError($0))" } ?? ""
throw CoreError.timeout("ssh on \(host):\(port)\(detail)") throw CoreError.timeout("ssh on \(host):\(port) after \(attempt) attempts\(detail)")
}
/// Renders a probe failure for humans.
///
/// Goes through `asCoreError` rather than interpolating raw: a connect failure
/// is where the Local Network privacy hint lives, and these strings are the only
/// place most operators will ever see why a boot stalled.
private func renderSSHWaitError(_ error: Error) -> String {
(error as? SSHTransportError).map { "\($0.asCoreError)" } ?? "\(error)"
} }
+130
View File
@@ -0,0 +1,130 @@
import Foundation
/// The decisions in an Xcode install that are pure string and arithmetic work.
///
/// Installing Xcode into a guest is a long chain of SSH commands, and the parts
/// of it that are easy to get wrong — which `.app` came out of the archive, how
/// much disk the expansion is going to want, what `df` actually said — are all
/// decidable from text. They live here so they can be tested without a VM,
/// which is the only way they ever get tested: the surrounding code takes forty
/// minutes and a 12 GB file to run once.
public enum XcodeInstall {
// MARK: - Which app came out of the archive
/// Picks the expanded application bundle out of an `ls -d …/*.app` listing.
///
/// The archive's payload is *not* reliably named `Xcode.app`. Beta releases
/// expand to `Xcode-beta.app`, and Apple has shipped version-qualified names
/// before, so the name has to be discovered rather than assumed — hardcoding
/// it is what made a successful 12 GB upload and a 30-minute expansion fail
/// on the very last `mv`.
///
/// - Parameters:
/// - listing: Standard output of `ls -d <staging>/*.app`.
/// - staging: The directory that was listed, named in error messages.
/// - Returns: The full path of the single matching bundle.
/// - Throws: ``CoreError/provisioningFailed(_:)`` for zero or several
/// matches. Both are ambiguous rather than recoverable: picking one of two
/// candidates would install something the operator did not ask for.
public static func expandedAppPath(fromListing listing: String, staging: String) throws -> String {
let candidates = listing
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
// An unmatched glob is echoed back verbatim by /bin/sh, so the
// no-match case arrives looking like a path that ends in `*.app`.
.filter { !$0.contains("*") }
switch candidates.count {
case 1:
return candidates[0]
case 0:
throw CoreError.provisioningFailed(
"the Xcode archive expanded but produced no .app in \(staging). "
+ "The download may be truncated — check the .xip and try again."
)
default:
let names = candidates.map { ($0 as NSString).lastPathComponent }
throw CoreError.provisioningFailed(
"the Xcode archive expanded to \(candidates.count) applications in \(staging) "
+ "(\(names.joined(separator: ", "))), so it is not clear which to install. "
+ "Expand the .xip by hand and pass a single-application archive."
)
}
}
// MARK: - Disk
/// How much room the expansion of an archive is expected to need, excluding
/// the archive itself.
///
/// A `.xip` is an LZMA-compressed cpio of the whole application, and Xcode
/// compresses well: recent releases land near 3.5× on expansion. This is an
/// estimate used for a pre-flight, so it is deliberately the ratio at the
/// pessimistic end of what has been observed rather than an average — the
/// cost of overestimating is a clear error message, and the cost of
/// underestimating is a guest that runs out of disk 35 minutes in.
public static func expansionEstimateBytes(xipBytes: Int) -> Int {
(xipBytes * 7) / 2
}
/// Total free space the guest needs before the upload starts: the uploaded
/// archive plus everything it expands into.
///
/// Both have to coexist — `xip --expand` reads the archive while it writes —
/// and the archive is deleted as soon as the expansion succeeds, before the
/// move, which is a same-volume rename that needs no headroom of its own.
public static func requiredFreeBytes(xipBytes: Int) -> Int {
xipBytes + expansionEstimateBytes(xipBytes: xipBytes)
}
/// Reads the available-bytes column out of `df -Pk` output.
///
/// `-P` matters: without it `df` wraps a long device name onto its own line
/// and the columns stop lining up. `-k` fixes the block size at 1024, so the
/// value does not depend on the guest's `BLOCKSIZE`.
///
/// - Returns: Free bytes, or `nil` if the output was not in the expected
/// shape — the caller treats that as "could not check" rather than as a
/// failure, since refusing to install because `df` was unparseable would
/// be worse than the risk it guards against.
public static func availableBytes(dfOutput: String) -> Int? {
for line in dfOutput.split(separator: "\n") {
let fields = line.split(whereSeparator: \.isWhitespace)
guard fields.count >= 4, fields[0] != "Filesystem",
let kilobytes = Int(fields[3])
else { continue }
return kilobytes * 1024
}
return nil
}
/// Renders a byte count the way the progress lines do, e.g. `12.4 GB`.
///
/// Decimal gigabytes, matching how the archives are advertised and how
/// Finder reports them, so the number in an error message is the number the
/// operator can see on their own disk.
public static func formatGB(_ bytes: Int) -> String {
String(format: "%.1f GB", Double(bytes) / 1_000_000_000)
}
/// The message shown when the guest cannot fit the install.
///
/// Built here, with the numbers spelled out, because "no space left on
/// device" 35 minutes into an expansion tells the operator nothing about how
/// much bigger the image needed to be.
public static func insufficientDiskMessage(xipBytes: Int, availableBytes: Int) -> String {
let needed = requiredFreeBytes(xipBytes: xipBytes)
return """
not enough free disk in the guest to install Xcode: \
\(formatGB(availableBytes)) available, about \(formatGB(needed)) needed \
(\(formatGB(xipBytes)) for the archive plus roughly \
\(formatGB(expansionEstimateBytes(xipBytes: xipBytes))) once expanded).
Rebuild the base image with a larger disk, e.g.:
gitea-macos-runner image delete <name>
gitea-macos-runner image build --ipsw <path> --disk-gb 200
"""
}
}
+307 -65
View File
@@ -78,21 +78,29 @@ public enum Doctor {
/// running binary, read with `codesign -d --entitlements - <path>`. /// running binary, read with `codesign -d --entitlements - <path>`.
/// Running from `.build/` instead of the signed `.app` is the single most /// Running from `.build/` instead of the signed `.app` is the single most
/// common setup mistake, and this is what catches it. /// common setup mistake, and this is what catches it.
/// 5. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests /// 5. **Code identity is stable**, i.e. the bundle is signed with a real
/// team-anchored certificate rather than ad-hoc. Warns on ad-hoc,
/// because that is what makes Local Network grants evaporate on every
/// rebuild (check 12).
/// 6. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests
/// write. /// write.
/// 6. **`login.keychain` unlocked**, via `security show-keychain-info /// 7. **`login.keychain` unlocked**, via `security show-keychain-info
/// login.keychain`. macOS 15+ refuses to start a VM otherwise — the /// login.keychain`. macOS 15+ refuses to start a VM otherwise — the
/// reason the daemon must be a LaunchAgent in a logged-in session. /// reason the daemon must be a LaunchAgent in a logged-in session.
/// 7. **Gitea reachable and the token has admin scope**, probed with /// 8. **Gitea reachable and the token has admin scope**, probed with
/// ``GiteaClient/listRunners()``. A non-admin token fails here rather /// ``GiteaClient/listRunners()``. A non-admin token fails here rather
/// than at the first poll. /// than at the first poll.
/// 8. **Registration token resolvable** from file, inline value, or (if /// 9. **Registration token resolvable** from file, inline value, or (if
/// enabled) the API. /// enabled) the API.
/// 9. **Runner download URL is live**, via a one-byte ranged `GET` — the /// 10. **Runner download URL is live**, via a one-byte ranged `GET` — the
/// 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**. Passes when a subnet allowlist is set in /// 11. **Guest SSH**, against whichever slot currently holds a DHCP lease —
/// the one check that exercises host → vmnet → guest `sshd` → password
/// auth end to end. Informational when no guest is up, since `doctor`
/// will not boot one.
/// 12. **Local Network privacy**. Passes when a subnet allowlist is set in
/// `com.apple.network.local-network`; otherwise informational. On /// `com.apple.network.local-network`; otherwise informational. On
/// macOS 15+ the first attempt to reach a guest over the NAT link can /// macOS 15+ the first attempt to reach a guest over the NAT link can
/// be blocked by the Local Network permission prompt, which a /// be blocked by the Local Network permission prompt, which a
@@ -107,6 +115,7 @@ public enum Doctor {
checks.append(checkLoginKeychain()) checks.append(checkLoginKeychain())
checks.append(contentsOf: await checkGitea(config: config)) checks.append(contentsOf: await checkGitea(config: config))
checks.append(await checkRunnerDownloadURL(config: config)) checks.append(await checkRunnerDownloadURL(config: config))
checks.append(await checkGuestSSH(config: config))
checks.append(localNetworkNote()) checks.append(localNetworkNote())
return checks return checks
} }
@@ -148,18 +157,20 @@ public enum Doctor {
checks.append(checkLoginKeychain()) checks.append(checkLoginKeychain())
checks.append(contentsOf: await checkGitea(config: loaded)) checks.append(contentsOf: await checkGitea(config: loaded))
checks.append(await checkRunnerDownloadURL(config: loaded)) checks.append(await checkRunnerDownloadURL(config: loaded))
checks.append(await checkGuestSSH(config: loaded))
checks.append(contentsOf: checkTokenFilePermissions(config: loaded)) checks.append(contentsOf: checkTokenFilePermissions(config: loaded))
checks.append(localNetworkNote()) checks.append(localNetworkNote())
return checks return checks
} }
/// The configuration-independent host checks: architecture, OS version, /// The configuration-independent host checks: architecture, OS version,
/// framework support, entitlement. /// framework support, entitlement, code identity.
public static func hostChecks() -> [DoctorCheck] { public static func hostChecks() -> [DoctorCheck] {
[ [
checkHostCapability(), checkHostCapability(),
checkVirtualizationSupported(), checkVirtualizationSupported(),
checkVirtualizationEntitlement(), checkVirtualizationEntitlement(),
checkCodeSignature(),
] ]
} }
@@ -187,11 +198,7 @@ public enum Doctor {
// The entitlement lives on the signature, so a bare binary copied out of // The entitlement lives on the signature, so a bare binary copied out of
// the bundle loses it. Report where we are as well as what we found. // the bundle loses it. Report where we are as well as what we found.
let inAppBundle = executable.contains(".app/Contents/MacOS/") let (signedTarget, inAppBundle) = signableTarget(for: executable)
let signedTarget = inAppBundle
? String(executable.prefix(upTo: executable.range(of: ".app/Contents/MacOS/")!.upperBound)
.dropLast("/Contents/MacOS/".count))
: executable
let result = DoctorShell.run( let result = DoctorShell.run(
"/usr/bin/codesign", "/usr/bin/codesign",
@@ -226,6 +233,112 @@ public enum Doctor {
) )
} }
/// Whether the bundle's code identity is stable across rebuilds.
///
/// This is not a cosmetic "is it properly signed" check — it is the root
/// cause of the project's most confusing failure. A Developer ID signature
/// carries a designated requirement anchored to the team
/// (`certificate leaf[subject.OU] = "…"`), so macOS recognises every later
/// build as the same program and the app's Local Network grant persists. An
/// **ad-hoc** signature has no such anchor, so per
/// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
/// the system identifies the app by its main executable's Mach-O UUID —
/// which the linker regenerates on essentially every link. Each
/// `make install` therefore presents a program macOS has never seen, its
/// permission reverts to undetermined, and guest SSH starts failing with
/// `No route to host` minutes after a build that worked.
///
/// Ad-hoc is a `warn`, not a `fail`: everything still runs, and it is the
/// only option on a host without a certificate (CI signs this way
/// deliberately). It just needs the subnet allowlist to compensate.
///
/// - Parameter binaryPath: Defaults to the current executable.
/// - Returns: The check result.
public static func checkCodeSignature(
binaryPath: String = CommandLine.arguments.first ?? ""
) -> DoctorCheck {
let name = "code identity"
guard let executable = resolveExecutablePath(binaryPath) else {
return DoctorCheck(
name: name,
result: .warn,
detail: "could not locate the running executable to inspect",
remediation: "build and install the signed bundle: `make install`"
)
}
let (target, inAppBundle) = signableTarget(for: executable)
let result = DoctorShell.run("/usr/bin/codesign", ["-dv", target])
guard result.exitCode == 0 else {
return DoctorCheck(
name: name,
result: inAppBundle ? .fail : .warn,
detail: "\(target) carries no code signature",
remediation: "sign the bundle: `make sign` (or `make install`)"
)
}
let team = value(of: "TeamIdentifier", in: result.output)
let identifier = value(of: "Identifier", in: result.output) ?? "?"
let hardened = result.output.contains("flags=") && result.output.contains("runtime")
guard let team, team != "not set" else {
return DoctorCheck(
name: name,
result: .warn,
detail: "\(identifier) is ad-hoc signed (no team identifier)",
remediation: """
An ad-hoc signature has no stable designated requirement, so macOS falls back \
to identifying this app by its Mach-O UUID — regenerated on every build. Any \
Local Network grant is withdrawn by the next `make install`, and guests then \
fail with "No route to host". Sign with a Developer ID certificate \
(`make sign TEAM_ID=<team>`), or set the subnet allowlist so no grant is \
needed at all — see the "local network access" check.
"""
)
}
return DoctorCheck(
name: name,
result: .pass,
detail: "\(identifier), team \(team)"
+ (hardened ? ", hardened runtime" : "")
)
}
/// Reads a `Key=value` line out of `codesign -dv` output.
///
/// `codesign` writes this block to stderr, one `Key=value` per line, and
/// repeats some keys (`Authority`); the first match is the one that matters.
private static func value(of key: String, in output: String) -> String? {
for line in output.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("\(key)=") else { continue }
return String(trimmed.dropFirst(key.count + 1))
}
return nil
}
/// The artifact `codesign` should be pointed at: the enclosing `.app` when
/// the executable lives inside one, otherwise the executable itself.
///
/// Signatures and entitlements are sealed on the bundle, so querying the
/// bare Mach-O inside it — or one copied out of it — answers the wrong
/// question.
///
/// - Parameter executable: An absolute, symlink-resolved executable path.
/// - Returns: The path to query, and whether it is an `.app` bundle.
private static func signableTarget(for executable: String) -> (path: String, inAppBundle: Bool) {
guard let marker = executable.range(of: ".app/Contents/MacOS/") else {
return (executable, false)
}
let bundle = executable.prefix(upTo: marker.upperBound)
.dropLast("/Contents/MacOS/".count)
return (String(bundle), true)
}
/// Whether `login.keychain` is currently unlocked. /// Whether `login.keychain` is currently unlocked.
public static func checkLoginKeychain() -> DoctorCheck { public static func checkLoginKeychain() -> DoctorCheck {
let name = "login.keychain unlocked" let name = "login.keychain unlocked"
@@ -523,71 +636,200 @@ public enum Doctor {
] ]
} }
/// The macOS 15+ Local Network permission note. /// Whether a guest that is up right now actually accepts an SSH session.
/// ///
/// Reports `.pass` when the host carries a subnet allowlist, because that /// Every other check in this file inspects the host. This one exercises the
/// bypasses the prompt entirely. Otherwise it stays informational: we /// exact path the boot sequence depends on and that nothing else proves:
/// cannot see the grant itself, since Local Network privacy is a Network /// host → vmnet → guest `sshd` → password auth with `guest.username` /
/// Extension packet filter rather than a TCC entry, so there is no /// `guest.password`. It is the difference between "the daemon never got a
/// database to query and `tccutil` does not apply (Apple, TN3179). /// runner online" and a named cause — wrong credentials, Local Network
public static func localNetworkNote() -> DoctorCheck { /// privacy blocking the link, or a guest image whose Remote Login is off.
let name = "local network access" ///
let allowed = localNetworkAllowlist() /// Read-only with respect to host state: it uses the MACs already persisted
if !allowed.isEmpty { /// in `state.json` and never generates them, so running `doctor` on a fresh
/// host does not quietly create the slot address table.
///
/// - Note: Informational when no guest is currently leased. `doctor` must not
/// boot a VM — that costs minutes and a slot out of the host's hard cap of
/// two — so with nothing running there is simply nothing to probe. To make
/// this check meaningful, leave a guest up (`gitea-macos-runner vm boot`)
/// and run `doctor` again.
public static func checkGuestSSH(config: RunnerConfig) async -> DoctorCheck {
let name = "guest ssh"
let macs: [String]
do {
macs = try VMStore(config: config).loadState().slotMACAddresses
} catch {
return DoctorCheck(
name: name,
result: .warn,
detail: "could not read host state: \(error)",
remediation: "check that \(config.storeDirectoryURL.path) is readable"
)
}
guard !macs.isEmpty else {
return DoctorCheck(
name: name,
result: .info,
detail: "no slot MAC addresses assigned yet; skipped",
remediation: nil
)
}
// Newest lease wins if a slot somehow holds more than one: that is the
// guest currently on the link.
let leases = DHCPLeaseParser.parseFile()
guard let (mac, lease) = macs.lazy
.compactMap({ mac in DHCPLeaseParser.lease(forMAC: mac, in: leases).map { (mac, $0) } })
.first
else {
return DoctorCheck(
name: name,
result: .info,
detail: "no guest currently holds a DHCP lease; skipped",
remediation: """
this check only runs against a guest that is already up. To exercise the \
host→guest SSH path, run `gitea-macos-runner vm boot --image default` and \
then `doctor` again.
"""
)
}
// Short and fixed rather than derived from `scheduler.bootTimeoutSeconds`:
// this probes a guest that is already booted, so a slow answer is a
// finding, not something to wait fifteen minutes for.
do {
try await waitForSSH(
host: lease.ipAddress,
username: config.guest.username,
password: config.guest.password,
timeout: .seconds(20),
pollInterval: .seconds(2)
)
return DoctorCheck( return DoctorCheck(
name: name, name: name,
result: .pass, result: .pass,
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))" detail: "authenticated to \(config.guest.username)@\(lease.ipAddress) (\(mac))"
)
} catch let error as CoreError {
let detail = "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)"
switch error {
case .timeout:
// Nothing answered. bootpd leases last 24 hours and the slot MACs
// are persistent, so on any host that has ever run the daemon the
// most likely explanation is a lease outliving the guest that held
// it — not a broken host. Calling that `.fail` would make `doctor`
// cry wolf on a perfectly healthy idle machine.
return DoctorCheck(
name: name,
result: .warn,
detail: detail,
remediation: """
most likely a stale lease: bootpd keeps leases for 24 hours, so this \
address may belong to a guest that has already been torn down. If a \
guest really is up at this address, the daemon cannot reach it either — \
check that the host's Local Network permission is not dropping the \
connection (see the "local network access" check).
"""
)
default:
// Authentication reached the guest and was refused: the guest is up
// and the credentials are wrong. Nothing about that improves on its
// own, and every boot will fail the same way.
return DoctorCheck(
name: name,
result: .fail,
detail: detail,
remediation: """
the daemon authenticates over this exact path, so no boot can succeed \
while it fails. Check that guest.username and guest.password match an \
account in the guest image, and that Remote Login is enabled there.
"""
)
}
} catch {
return DoctorCheck(
name: name,
result: .warn,
detail: "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)",
remediation: nil
) )
} }
}
/// The macOS 15+ Local Network permission note.
///
/// Reports `.pass` when the host carries a subnet allowlist that actually
/// covers where guests turn up, because that bypasses the prompt entirely.
/// An allowlist that names some *other* subnet is worse than none, since it
/// looks configured while blocking every guest, so it warns rather than
/// passing. Without one this 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 {
let name = "local network access"
let status = LocalNetworkPermission.observedStatus()
if status.isConfigured {
if status.coversGuestRange {
return DoctorCheck(
name: name,
result: .pass,
detail: "subnet allowlist set: \(status.allowlist.joined(separator: ", "))"
)
}
return DoctorCheck(
name: name,
result: .warn,
detail: "subnet allowlist set but does not cover the guest range: "
+ status.allowlist.joined(separator: ", "),
remediation: """
Virtualization.framework's NAT does not stay on 192.168.64.0/24 — it moves \
to the next free /24 (192.168.65.x, .66.x, …) when one is taken, so \
an allowlist pinned to a single /24 stops working the day the subnet shifts \
and every guest connection then fails with "No route to host". Widen it: \
`gitea-macos-runner permissions grant`, then reboot. See docs/setup.md §2.6.
"""
)
}
// Nothing found. On all but an unusual host that means *not visible*
// rather than *not set*: the allowlist is written as root and lands in
// /var/root, which is mode 700. Say which of the two this is, because
// "no allowlist" would otherwise be asserted on a host that has one.
let caveat =
status.isIndeterminate
? """
This cannot see the setting itself — it lives in \
\(status.unreadablePaths[0]), which only root can read — so treat the above as \
"not visible", not "not set". `sudo gitea-macos-runner permissions status` \
answers definitively, and `permissions grant` verifies its own write.
"""
: ""
return DoctorCheck( return DoctorCheck(
name: name, name: name,
result: .info, result: .info,
detail: "guests are reached over the host-private NAT link", detail: status.isIndeterminate
? "no allowlist visible; guests are reached over the host-private NAT link"
: "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. The app cannot be \ privacy prompt, and frequently there is nothing able to answer it. A LaunchAgent \
pre-approved: it only appears under System Settings → Privacy & Security → Local \ has no UI to show it in; a run started from a shell is attributed to the \
Network once it has actually attempted a guest connection. To trigger and answer \ *responsible* process, so both the prompt and the System Settings → Privacy & \
the prompt by hand, run `gitea-macos-runner vm boot --image default` once from a \ Security → Local Network row belong to Terminal rather than to this app — and \
Terminal in the GUI session. On an unattended CI host prefer the subnet \ granting it to Terminal does not carry over to the LaunchAgent. Grant it with \
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \ `gitea-macos-runner permissions grant`, which writes a subnet allowlist that \
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \ needs no prompt, covers every process, and survives rebuilds — then reboot. \
"192.168.64.0/24" (then reboot). See docs/setup.md §2.6. `permissions status` explains both routes. See docs/setup.md §2.6.
""" """ + caveat
) )
} }
/// 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
+187 -29
View File
@@ -304,53 +304,134 @@ public struct GuestProvisioner: Sendable {
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for /// running `xcodebuild -runFirstLaunch` so the first job does not pay for
/// component installation. /// component installation.
/// ///
/// The application's name is **discovered, not assumed**. A release archive
/// expands to `Xcode.app`, a beta to `Xcode-beta.app`, and Apple has shipped
/// version-qualified names too; whatever comes out keeps its name under
/// `/Applications`, because `xcode-select -s` makes the name irrelevant to
/// anything that builds.
///
/// - Parameters: /// - Parameters:
/// - executor: A connected guest executor. /// - executor: A connected guest executor.
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded. /// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
public func installXcode(executor: any GuestExecutor, xipPath: String) async throws { /// - progress: Optional stage callback. Every phase here runs for tens of
/// minutes, so silence is indistinguishable from a hang — this is the
/// only thing that says otherwise.
public func installXcode(
executor: any GuestExecutor,
xipPath: String,
progress: (@Sendable (String) -> Void)? = nil
) async throws {
let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath) let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
guard FileManager.default.fileExists(atPath: localURL.path) else { guard FileManager.default.fileExists(atPath: localURL.path) else {
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)") throw CoreError.notFound("Xcode .xip not found at \(localURL.path)")
} }
let localBytes =
(try? FileManager.default.attributesOfItem(atPath: localURL.path))?[.size] as? Int ?? 0
let remoteXIP = "/tmp/Xcode.xip" let remoteXIP = "/tmp/Xcode.xip"
// Uploads go over an SSH exec channel with the payload as stdin, and
// `GuestExecutor.upload` reads the whole local file into memory first —
// fine for a 90 MB pkg, ruinous for a 12 GB xip. So this streams the file
// in bounded chunks and appends them guest-side instead. It is still slow
// (an exec channel is not SCP), but it is functional and its host memory
// use is capped at one chunk.
try await executor.runChecked("rm -f \(Self.shellQuote(remoteXIP))", timeout: .seconds(120))
try await Self.uploadLargeFile(executor: executor, localURL: localURL, remotePath: remoteXIP)
// Free the disk the old copy occupies before expanding into ~40 GB more.
_ = try? await executor.run("sudo -n rm -rf /Applications/Xcode.app", timeout: .seconds(600))
let staging = "/tmp/xcode-expand" let staging = "/tmp/xcode-expand"
// `xip --expand` writes into the current directory and needs no sudo, but let quotedXIP = Self.shellQuote(remoteXIP)
// /tmp is small on some layouts; staging under /tmp keeps it beside the let quotedStaging = Self.shellQuote(staging)
// archive so the later move is a rename within one volume where possible.
try await executor.runChecked(
"rm -rf \(Self.shellQuote(staging)) && mkdir -p \(Self.shellQuote(staging))",
timeout: .seconds(300)
)
// Expansion of a full Xcode takes 20–45 minutes on VM-backed storage. // Is a previous run's expansion still sitting there, complete? Then the
try await executor.runChecked( // upload and the expansion — between them the entire cost of this
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))", // function — are already paid for. Opportunistic only: macOS clears /tmp
timeout: .seconds(5400) // on boot, so after the guest has been power-cycled this finds nothing,
) // which is fine.
var expandedApp = try await Self.reusableExpandedApp(executor: executor, staging: staging)
if let expandedApp {
progress?("reusing expanded \((expandedApp as NSString).lastPathComponent)")
} else {
try await Self.checkGuestDisk(executor: executor, xipBytes: localBytes, progress: progress)
if try await Self.hasMatchingUpload(
executor: executor, remotePath: remoteXIP, expectedBytes: localBytes)
{
progress?("reusing uploaded xip (\(XcodeInstall.formatGB(localBytes)))")
} else {
// Uploads go over an SSH exec channel with the payload as stdin,
// and `GuestExecutor.upload` reads the whole local file into
// memory first — fine for a 90 MB pkg, ruinous for a 12 GB xip.
// So this streams the file in bounded chunks and appends them
// guest-side instead. It is still slow (an exec channel is not
// SCP), but it is functional and its host memory use is capped at
// one chunk.
let headline = "uploading Xcode (\(XcodeInstall.formatGB(localBytes)))"
progress?(headline + " 0%")
try await executor.runChecked("rm -f \(quotedXIP)", timeout: .seconds(120))
try await Self.uploadLargeFile(
executor: executor,
localURL: localURL,
remotePath: remoteXIP,
progress: { fraction in
progress?(headline + " \(Int(fraction * 100))%")
}
)
progress?(headline + " 100%")
}
// A half-finished expansion from an earlier attempt would leave
// `ls *.app` ambiguous, or leave a truncated bundle to be installed.
// Clear it before, not after.
try await executor.runChecked(
"rm -rf \(quotedStaging) && mkdir -p \(quotedStaging)", timeout: .seconds(600))
// `xip --expand` writes into the current directory and needs no sudo,
// but staging beside the archive keeps the later move a rename within
// one volume. Expansion of a full Xcode takes 20–45 minutes on
// VM-backed storage.
progress?("expanding xip (takes 15-40 min)…")
try await executor.runChecked(
"cd \(quotedStaging) && sudo -n /usr/bin/xip --expand \(quotedXIP)",
timeout: .seconds(5400)
)
// Immediately, and unconditionally on success: the archive is dead
// weight from here on, and the guest is at its tightest right now
// holding both copies. Deleting it as part of a success-only `&&`
// chain at the very end — which is what this used to do — means a
// failure anywhere later strands 12 GB in /tmp.
_ = try? await executor.run("rm -f \(quotedXIP)", timeout: .seconds(300))
let listing = try await executor.run(
"ls -d \(quotedStaging)/*.app 2>/dev/null", timeout: .seconds(300))
expandedApp = try XcodeInstall.expandedAppPath(
fromListing: listing.stdout, staging: staging)
}
guard let sourceApp = expandedApp else {
throw CoreError.provisioningFailed("could not locate the expanded Xcode in \(staging)")
}
let appName = (sourceApp as NSString).lastPathComponent
let destination = "/Applications/" + appName
let quotedDestination = Self.shellQuote(destination)
// Whatever is already there loses. This is a golden image being built to
// a specification, not a user's Mac, and leaving the old copy would both
// fail the move and waste tens of gigabytes in every clone.
let existing = try await executor.run(
"test -e \(quotedDestination) && echo present", timeout: .seconds(120))
if existing.stdout.contains("present") {
progress?("replacing existing \(appName) in the guest")
try await executor.runChecked(
"sudo -n rm -rf \(quotedDestination)", timeout: .seconds(1800))
}
// Writing into /Applications needs root. // Writing into /Applications needs root.
progress?("installing \(appName)…")
try await executor.runChecked( try await executor.runChecked(
"sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app " "sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)",
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
timeout: .seconds(1800) timeout: .seconds(1800)
) )
_ = try? await executor.run("rm -rf \(quotedStaging)", timeout: .seconds(600))
// xcode-select writes /var/db/xcode_select_link — root only. // xcode-select writes /var/db/xcode_select_link — root only. Pointing it
// at the discovered path is what makes the bundle's name a non-issue:
// `xcodebuild`, `swift`, and every `xcrun` shim resolve through this.
try await executor.runChecked( try await executor.runChecked(
"sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app/Contents/Developer", "sudo -n /usr/bin/xcode-select -s "
+ Self.shellQuote(destination + "/Contents/Developer"),
timeout: .seconds(300) timeout: .seconds(300)
) )
@@ -361,6 +442,7 @@ public struct GuestProvisioner: Sendable {
"sudo -n /usr/bin/xcodebuild -license accept", "sudo -n /usr/bin/xcodebuild -license accept",
timeout: .seconds(600) timeout: .seconds(600)
) )
progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…")
try await executor.runChecked( try await executor.runChecked(
"sudo -n /usr/bin/xcodebuild -runFirstLaunch", "sudo -n /usr/bin/xcodebuild -runFirstLaunch",
timeout: .seconds(3600) timeout: .seconds(3600)
@@ -373,6 +455,82 @@ public struct GuestProvisioner: Sendable {
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr) + Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
) )
} }
// Proof, in the operator's log, that the thing they waited an hour for
// is actually there and selected — on one line, since `-version` prints
// two.
let version = check.stdout
.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.joined(separator: " — ")
progress?("Xcode ready: \(version) at \(destination)")
}
/// An already-expanded application left by an earlier attempt, if one is
/// there and looks complete.
///
/// "Complete" is `Contents/MacOS` existing: an expansion killed part-way
/// leaves a directory tree that `ls` is perfectly happy to list, and
/// installing that would produce an Xcode that fails at first use rather
/// than at install time. Never throws — a guest with nothing staged is the
/// normal case, and an ambiguous listing here just means "do it properly".
static func reusableExpandedApp(
executor: any GuestExecutor,
staging: String
) async throws -> String? {
let listing = try await executor.run(
"ls -d \(shellQuote(staging))/*.app 2>/dev/null", timeout: .seconds(120))
guard let app = try? XcodeInstall.expandedAppPath(fromListing: listing.stdout, staging: staging)
else { return nil }
let complete = try await executor.run(
"test -d \(shellQuote(app + "/Contents/MacOS")) && echo ok", timeout: .seconds(120))
return complete.stdout.contains("ok") ? app : nil
}
/// Whether the guest already holds a byte-for-byte-sized copy of the upload.
///
/// Size only — hashing 12 GB over an exec channel would cost more than the
/// upload it is trying to avoid. The archive is written by this code alone,
/// to a fixed path, so a size match is strong enough evidence; a partial
/// upload from an interrupted run is shorter and fails the check.
static func hasMatchingUpload(
executor: any GuestExecutor,
remotePath: String,
expectedBytes: Int
) async throws -> Bool {
guard expectedBytes > 0 else { return false }
let result = try await executor.run(
"stat -f %z \(shellQuote(remotePath)) 2>/dev/null", timeout: .seconds(120))
let reported = Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines))
return reported == expectedBytes
}
/// Refuses the install before the upload when the guest cannot hold it.
///
/// The failure this replaces is the worst kind: `xip --expand` fills the
/// disk half an hour in, and the error names neither how much was needed nor
/// what to do about it. Unparseable `df` output is treated as "cannot check"
/// and allowed through — a pre-flight that blocks the install because it did
/// not recognise the output is worse than the problem.
static func checkGuestDisk(
executor: any GuestExecutor,
xipBytes: Int,
progress: (@Sendable (String) -> Void)?
) async throws {
guard xipBytes > 0 else { return }
let result = try await executor.run("df -Pk /", timeout: .seconds(120))
guard let available = XcodeInstall.availableBytes(dfOutput: result.stdout) else { return }
let needed = XcodeInstall.requiredFreeBytes(xipBytes: xipBytes)
guard available >= needed else {
throw CoreError.provisioningFailed(
XcodeInstall.insufficientDiskMessage(xipBytes: xipBytes, availableBytes: available)
)
}
progress?(
"guest disk: \(XcodeInstall.formatGB(available)) free, "
+ "\(XcodeInstall.formatGB(needed)) needed")
} }
/// The Node.js version installed when none is specified. /// The Node.js version installed when none is specified.
+4 -6
View File
@@ -155,12 +155,10 @@ public struct IPSWProvider: Sendable {
// not a Swift error — when handed a non-file or missing path, and an // not a Swift error — when handed a non-file or missing path, and an
// ObjC exception cannot be caught here. So the existence check is not // ObjC exception cannot be caught here. So the existence check is not
// politeness; it is the only thing standing between a typo and a crash. // politeness; it is the only thing standing between a typo and a crash.
var isDirectory: ObjCBool = false // The size and magic-byte checks alongside it cover the other way this
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), // call goes wrong: handed a partial download it neither fails nor
!isDirectory.boolValue // reports progress, it simply stops responding.
else { try IPSWFile.validate(path: url.path)
throw CoreError.notFound("restore image not found at \(url.path)")
}
do { do {
return try await VZMacOSRestoreImage.image(from: url) return try await VZMacOSRestoreImage.image(from: url)
+192 -22
View File
@@ -6,10 +6,16 @@ import Virtualization
public enum ImageBuildStage: Sendable, Equatable { public enum ImageBuildStage: Sendable, Equatable {
/// Downloading the IPSW. /// Downloading the IPSW.
case downloadingIPSW(fraction: Double) case downloadingIPSW(fraction: Double)
/// Reading the restore image and deriving a hardware configuration. /// Working out which file the operator meant and whether it is usable.
case preparing case preparing
/// Inside `VZMacOSRestoreImage.image(from:)`, which reports no progress of
/// its own and is the longest silent stretch of a local-IPSW build.
case loadingRestoreImage
/// Creating the disk, NVRAM, and bundle metadata. /// Creating the disk, NVRAM, and bundle metadata.
case creatingBundle case creatingBundle(diskGB: Int)
/// An out-of-band remark about the stage in flight — printed on its own
/// line rather than replacing the status line.
case note(String)
/// `VZMacOSInstaller` is writing macOS onto the disk. /// `VZMacOSInstaller` is writing macOS onto the disk.
case installing(fraction: Double) case installing(fraction: Double)
/// First boot; waiting for Setup Assistant, a DHCP lease, and SSH. /// First boot; waiting for Setup Assistant, a DHCP lease, and SSH.
@@ -73,9 +79,29 @@ public struct ImageBuilder: Sendable {
// Checked against the filesystem rather than `store.image(named:)`: a // Checked against the filesystem rather than `store.image(named:)`: a
// half-built bundle from a previous failed run is exactly the thing this // half-built bundle from a previous failed run is exactly the thing this
// needs to catch, and it would not read back as a valid image. // needs to look at, and it would not read back as a valid image.
let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true) let bundleURL = store.imagesDir.appendingPathComponent(name, isDirectory: true)
if FileManager.default.fileExists(atPath: bundleURL.path) { if FileManager.default.fileExists(atPath: bundleURL.path) {
let existing = VMBundle(rootURL: bundleURL)
let existingConfig = try? existing.loadConfig()
// Installed but never provisioned: resume rather than throw away an
// hour of installing. This is safe precisely because provisioning is
// what got skipped — the guest has never been booted, so its *true*
// first boot is still ahead of it and `VZMacGuestProvisioningOptions`
// will still be evaluated. (macOS only honours those options on the
// first boot after a restore; a guest that already booted once has
// consumed that chance, which is the ambiguous case handled by the
// SSH probe in `bootProvisionAndSeal`.)
if existing.isComplete(), let existingConfig, !existingConfig.provisioned {
progress?(
.note("image '\(name)' already installed — resuming first boot + provisioning"))
try await firstBootAndProvision(
bundle: existing, config: config, isResume: true, progress: progress)
progress?(.done)
return
}
throw CoreError.configInvalid( throw CoreError.configInvalid(
"image '\(name)' already exists at \(bundleURL.path). " "image '\(name)' already exists at \(bundleURL.path). "
+ "Delete it first (`image delete \(name)`), or build under a different --name." + "Delete it first (`image delete \(name)`), or build under a different --name."
@@ -89,7 +115,28 @@ public struct ImageBuilder: Sendable {
let provider = IPSWProvider(downloadDirectory: store.ipswDir) let provider = IPSWProvider(downloadDirectory: store.ipswDir)
let restoreImage: VZMacOSRestoreImage let restoreImage: VZMacOSRestoreImage
if let ipswPath { if let ipswPath {
progress?(.downloadingIPSW(fraction: 1.0)) progress?(.preparing)
progress?(.loadingRestoreImage)
// Reading a 21 GB archive's metadata takes a while and the
// framework says nothing while it does. A build that looks frozen
// is the single most-reported symptom of this command, so say out
// loud what the other likely explanation is rather than letting the
// operator guess.
let watchdog = Task {
try? await Task.sleep(for: .seconds(60))
// Cancelling is how a *fast* load ends this task, and a
// cancelled sleep returns rather than throwing past `try?` — so
// without this the note prints on every quick failure, which is
// precisely when it is misleading.
guard !Task.isCancelled else { return }
progress?(
.note(
"still loading — a truncated or partially downloaded .ipsw can block here; "
+ "verify the download completed"))
}
defer { watchdog.cancel() }
restoreImage = try await provider.load(localPath: ipswPath) restoreImage = try await provider.load(localPath: ipswPath)
} else { } else {
progress?(.downloadingIPSW(fraction: 0)) progress?(.downloadingIPSW(fraction: 0))
@@ -100,8 +147,7 @@ public struct ImageBuilder: Sendable {
} }
// 2/3. Hardware model and bundle. // 2/3. Hardware model and bundle.
progress?(.preparing) progress?(.creatingBundle(diskGB: config.guest.diskGB))
progress?(.creatingBundle)
let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config) let bundle = try await createBundle(name: name, restoreImage: restoreImage, config: config)
// 4. Install. // 4. Install.
@@ -344,19 +390,47 @@ public struct ImageBuilder: Sendable {
} }
installer.install { result in installer.install { result in
// `VZVirtualMachine` holds an exclusive lock on the bundle's
// auxiliary storage (nvram.bin) for its whole lifetime, and
// releases it in `dealloc`. The next thing the caller does is
// build a *second* VM over the same bundle for first boot, so
// if this one is still alive at that moment the new one fails
// validation with "Failed to lock auxiliary storage" — which
// is exactly what operators hit.
//
// Hence: drop every strong reference here, on the queue that
// owns these objects...
session.observation?.invalidate()
session.observation = nil session.observation = nil
session.installer = nil session.installer = nil
session.virtualMachine = nil session.virtualMachine = nil
switch result {
case .success: // ...and resume the caller only from a *later* block on that
progress?(1.0) // same serial queue. Returning from this handler is what lets
continuation.resume() // the framework's own frame unwind and release its references,
case .failure(let error): // and a serial queue guarantees that has happened before the
continuation.resume(throwing: VMInstance.mapVZError(error)) // block below runs. Resuming inline instead would race the
// deallocation against the first-boot VM.
let boxedResult = UncheckedBox(result)
queue.async {
switch boxedResult.value {
case .success:
progress?(1.0)
continuation.resume()
case .failure(let error):
continuation.resume(throwing: VMInstance.mapVZError(error))
}
} }
} }
} }
} }
// One more hop to the back of the same queue: by the time an empty block
// gets to run, everything enqueued above it — including the release of
// the last reference to the VM — has finished.
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
queue.async { continuation.resume() }
}
} }
/// Boots the freshly installed guest, gets it onto the network, and hands it /// Boots the freshly installed guest, gets it onto the network, and hands it
@@ -390,10 +464,14 @@ public struct ImageBuilder: Sendable {
/// - Parameters: /// - Parameters:
/// - bundle: The installed bundle. /// - bundle: The installed bundle.
/// - config: Guest credentials and timeouts. /// - config: Guest credentials and timeouts.
/// - isResume: `true` when this is picking up a bundle that was installed
/// by an earlier run. Only affects the advice given if the guest never
/// answers on SSH — see ``bootProvisionAndSeal(bundle:config:startOptions:isFirstBoot:isResume:xcodeXIPPath:progress:)``.
/// - progress: Stage callback. /// - progress: Stage callback.
public func firstBootAndProvision( public func firstBootAndProvision(
bundle: VMBundle, bundle: VMBundle,
config: RunnerConfig, config: RunnerConfig,
isResume: Bool = false,
progress: (@Sendable (ImageBuildStage) -> Void)? = nil progress: (@Sendable (ImageBuildStage) -> Void)? = nil
) async throws { ) async throws {
guard #available(macOS 27.0, *) else { guard #available(macOS 27.0, *) else {
@@ -434,6 +512,7 @@ public struct ImageBuilder: Sendable {
config: config, config: config,
startOptions: startOptions, startOptions: startOptions,
isFirstBoot: true, isFirstBoot: true,
isResume: isResume,
xcodeXIPPath: nil, xcodeXIPPath: nil,
progress: progress progress: progress
) )
@@ -478,6 +557,7 @@ public struct ImageBuilder: Sendable {
config: config, config: config,
startOptions: nil, startOptions: nil,
isFirstBoot: false, isFirstBoot: false,
isResume: false,
xcodeXIPPath: xcodeXIPPath, xcodeXIPPath: xcodeXIPPath,
progress: progress progress: progress
) )
@@ -493,6 +573,7 @@ public struct ImageBuilder: Sendable {
config: RunnerConfig, config: RunnerConfig,
startOptions: VZMacOSVirtualMachineStartOptions?, startOptions: VZMacOSVirtualMachineStartOptions?,
isFirstBoot: Bool, isFirstBoot: Bool,
isResume: Bool,
xcodeXIPPath: String?, xcodeXIPPath: String?,
progress: (@Sendable (ImageBuildStage) -> Void)? progress: (@Sendable (ImageBuildStage) -> Void)?
) async throws { ) async throws {
@@ -501,15 +582,11 @@ public struct ImageBuilder: Sendable {
let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds)) let bootTimeout = Duration.seconds(max(60, config.scheduler.bootTimeoutSeconds))
progress?(.firstBoot) progress?(.firstBoot)
let instance = try VMInstance(bundle: bundle, label: "image:\(bundle.name)", headless: true) let instance = try await Self.bootRetryingAuxStorageLock(
bundle: bundle,
do { startOptions: startOptions,
try await instance.start(options: startOptions) progress: progress
} catch { )
throw CoreError.provisioningFailed(
"could not boot image '\(bundle.name)': \(error)"
)
}
let address: String let address: String
do { do {
@@ -526,6 +603,37 @@ public struct ImageBuilder: Sendable {
} catch { } catch {
_ = await instance.requestStopThenForce() _ = await instance.requestStopThenForce()
if isFirstBoot { if isFirstBoot {
// A resumed build has a second candidate cause, and it is
// unrecoverable rather than merely slow: macOS evaluates
// `VZMacGuestProvisioningOptions` only on the first boot after a
// restore. If the earlier run got far enough to boot the guest —
// which the bundle on disk cannot tell us — that chance is spent,
// and no amount of retrying will produce an account or sshd.
// Reaching SSH is the only way to distinguish the two, so this is
// said here, after the probe has failed, rather than refusing to
// resume in the first place.
if isResume {
throw CoreError.provisioningFailed(
"""
the resumed guest never became reachable over SSH within \
\(config.scheduler.bootTimeoutSeconds)s.
Either the guest is older than macOS 27 (see below), or an earlier run \
already consumed its first boot — macOS applies automated Setup Assistant \
provisioning only once, on the first boot after a restore, so a guest that \
has booted before can no longer be provisioned unattended.
There is no way to re-arm it: delete the image and build again with a \
macOS 27 or newer restore image.
gitea-macos-runner image delete \(bundle.name)
gitea-macos-runner image build --ipsw <path>
Underlying error: \(error)
"""
)
}
// The most likely cause by far, and the one with no diagnostic of // The most likely cause by far, and the one with no diagnostic of
// its own: a pre-27 guest accepts the provisioning options and // its own: a pre-27 guest accepts the provisioning options and
// ignores them, so it sits at Setup Assistant with no account and // ignores them, so it sits at Setup Assistant with no account and
@@ -564,10 +672,14 @@ public struct ImageBuilder: Sendable {
if let xcodeXIPPath { if let xcodeXIPPath {
progress?(.provisioning(step: "Xcode")) progress?(.provisioning(step: "Xcode"))
// Xcode is the one step measured in hours, so it reports its own
// sub-stages rather than going quiet behind a single headline.
try await provisioner.installXcode( try await provisioner.installXcode(
executor: executor, executor: executor,
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
) ) { step in
progress?(.provisioning(step: step))
}
} }
} catch { } catch {
await executor.close() await executor.close()
@@ -598,6 +710,64 @@ public struct ImageBuilder: Sendable {
/// Full name for the account Setup Assistant automation creates. /// Full name for the account Setup Assistant automation creates.
static let guestAccountFullName = "Gitea Runner" static let guestAccountFullName = "Gitea Runner"
/// Creates and starts the VM, tolerating a still-held auxiliary-storage lock.
///
/// `VZVirtualMachine` takes an exclusive lock on the bundle's `nvram.bin`
/// and gives it up only when the object deallocates. `install(bundle:…)`
/// now drains its queue before returning, so its installer VM is gone by
/// the time we get here — but "gone" is an ARC and Objective-C runtime
/// property, and a stray autorelease pool or a framework thread that has
/// not yet unwound can still be holding the last reference for a moment.
/// The failure that produces is not ambiguous and not persistent:
///
/// Invalid virtual machine configuration. Failed to lock auxiliary storage.
///
/// So it is retried, briefly and only for that message. Anything else fails
/// on the first attempt, because a genuinely invalid configuration does not
/// become valid by waiting.
static func bootRetryingAuxStorageLock(
bundle: VMBundle,
startOptions: VZMacOSVirtualMachineStartOptions?,
progress: (@Sendable (ImageBuildStage) -> Void)?,
timeout: Duration = .seconds(30),
pollInterval: Duration = .seconds(2)
) async throws -> VMInstance {
let started = ContinuousClock.now
var announced = false
while true {
do {
let instance = try VMInstance(
bundle: bundle, label: "image:\(bundle.name)", headless: true)
try await instance.start(options: startOptions)
return instance
} catch {
guard isAuxiliaryStorageLockFailure(error),
ContinuousClock.now - started < timeout
else {
throw CoreError.provisioningFailed(
"could not boot image '\(bundle.name)': \(error)"
)
}
if !announced {
announced = true
progress?(.note("waiting for installer to release the VM bundle…"))
}
try await Task.sleep(for: pollInterval)
}
}
}
/// Whether an error is the transient "someone else still has nvram.bin".
///
/// Matched on the message because the framework reports it as a generic
/// `VZError.invalidVirtualMachineConfiguration` with the detail only in the
/// description — there is no distinct code to switch on.
static func isAuxiliaryStorageLockFailure(_ error: any Error) -> Bool {
let text = "\(error)".lowercased()
return text.contains("auxiliary storage") && text.contains("lock")
}
/// Polls `/var/db/dhcpd_leases` until the guest's MAC appears. /// Polls `/var/db/dhcpd_leases` until the guest's MAC appears.
/// ///
/// - Parameters: /// - Parameters:
+57 -2
View File
@@ -47,10 +47,25 @@ public struct ServiceStatus: Sendable, Equatable {
/// because this is the failure people hit first. /// because this is the failure people hit first.
public enum LaunchdService { public enum LaunchdService {
/// The `launchd` label, matching `CFBundleIdentifier`. /// The `launchd` label, matching `CFBundleIdentifier`.
public static let label = "xyz.blakeslee.gitea-macos-runner" public static let label = "xyz.blakeslee.gitea-macos-vm-orchestrator"
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist`. /// Labels this service used to install under.
///
/// Renaming the label renames the plist, so an upgrade that only wrote the
/// new one would leave the old job bootstrapped and still running the old
/// binary — two daemons polling the same Gitea instance, racing to claim
/// the same queued jobs, with no hint in the logs that a second one exists.
/// ``install(executablePath:configPath:)`` and ``uninstall()`` therefore
/// evict these first. Append, never edit, when the label changes again.
public static let legacyLabels = ["xyz.blakeslee.gitea-macos-runner"]
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist`.
public static var agentPlistURL: URL { public static var agentPlistURL: URL {
agentPlistURL(for: label)
}
/// The LaunchAgent plist path for an arbitrary label.
public static func agentPlistURL(for label: String) -> URL {
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/LaunchAgents/\(label).plist")) URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/LaunchAgents/\(label).plist"))
} }
@@ -106,6 +121,11 @@ public enum LaunchdService {
withIntermediateDirectories: true withIntermediateDirectories: true
) )
// Upgrading from a build that installed under an older label: evict it
// before bootstrapping this one, or both run at once. See
// ``legacyLabels``.
removeLegacyAgents()
// A reinstall over a loaded job is the common case (upgrade, config // A reinstall over a loaded job is the common case (upgrade, config
// change), so unload before rewriting rather than failing on "already // change), so unload before rewriting rather than failing on "already
// bootstrapped". // bootstrapped".
@@ -140,13 +160,48 @@ public enum LaunchdService {
} }
/// Unloads the job and removes the plist. Safe when not installed. /// Unloads the job and removes the plist. Safe when not installed.
///
/// Also evicts any ``legacyLabels`` job, so `service uninstall` leaves
/// nothing of this project loaded regardless of which version installed it.
public static func uninstall() throws { public static func uninstall() throws {
removeLegacyAgents()
_ = try? uninstallJobOnly() _ = try? uninstallJobOnly()
if FileManager.default.fileExists(atPath: agentPlistURL.path) { if FileManager.default.fileExists(atPath: agentPlistURL.path) {
try FileManager.default.removeItem(at: agentPlistURL) try FileManager.default.removeItem(at: agentPlistURL)
} }
} }
/// Boots out and deletes any LaunchAgent installed under a ``legacyLabels``
/// entry.
///
/// Best effort by design: a legacy job that was never installed, is not
/// loaded, or whose plist is already gone is not an error, and failing to
/// evict one must not block installing the current job.
///
/// - Returns: The legacy labels that were actually found and removed, for
/// callers that want to tell the operator a migration happened.
@discardableResult
public static func removeLegacyAgents() -> [String] {
var removed: [String] = []
for legacy in legacyLabels {
let plist = agentPlistURL(for: legacy)
let bootout = LaunchdShell.run(
"/bin/launchctl", ["bootout", "\(domainTarget)/\(legacy)"])
if bootout.exitCode != 0 {
_ = LaunchdShell.run("/bin/launchctl", ["unload", "-w", plist.path])
}
if FileManager.default.fileExists(atPath: plist.path) {
try? FileManager.default.removeItem(at: plist)
removed.append(legacy)
} else if bootout.exitCode == 0 {
// Loaded, but from a plist that is no longer on disk.
removed.append(legacy)
}
}
return removed
}
/// Unloads the job but leaves the plist on disk. /// Unloads the job but leaves the plist on disk.
private static func uninstallJobOnly() throws { private static func uninstallJobOnly() throws {
let bootout = LaunchdShell.run("/bin/launchctl", ["bootout", serviceTarget]) let bootout = LaunchdShell.run("/bin/launchctl", ["bootout", serviceTarget])
@@ -0,0 +1,441 @@
import AppKit
import Darwin
import Foundation
import RunnerCore
/// Grants the host's Local Network access, so the operator does not have to
/// paste `sudo defaults write` incantations and work out for themselves that a
/// reboot is required.
///
/// Two routes, with genuinely different trade-offs — see ``Method``:
///
/// - ``grantViaAllowlist(subnets:allowPasswordPrompt:)`` writes the subnet
/// allowlist. Deterministic and process-independent, but inert until reboot.
/// - ``triggerPrompt(timeout:)`` provokes the real system prompt, attributed to
/// *this app* rather than to Terminal. Takes effect immediately, but depends
/// on macOS actually presenting the alert.
///
/// The pure parts — where the setting lives, what covers the guest range, what
/// to write — are `RunnerCore`'s ``LocalNetworkPolicy``. This type is the half
/// that runs processes.
public enum LocalNetworkPermission {
/// How to obtain the grant.
public enum Method: String, CaseIterable, Sendable {
/// Write the subnet allowlist. Needs `sudo` and a reboot.
case allowlist
/// Provoke the system prompt via LaunchServices. Needs a GUI session.
case prompt
}
/// The bundle identifier of the installed app.
///
/// Must match `Resources/Info.plist`. It is the same string as
/// ``LaunchdService/label`` by convention — the agent is named after the
/// bundle it launches — but they are read by different subsystems, so this
/// spells it out rather than aliasing.
public static let bundleIdentifier = "xyz.blakeslee.gitea-macos-vm-orchestrator"
// MARK: - Errors
public enum PermissionError: Error, CustomStringConvertible {
/// `sudo` could not be run non-interactively and there is no terminal
/// to prompt on. Carries the commands to run by hand.
case needsPassword(commands: [String])
/// A `defaults write` exited non-zero.
case writeFailed(command: String, exitCode: Int32)
/// `open -b` could not find the app.
case bundleNotRegistered
/// The probe process ran but left no report behind.
case probeProducedNoReport
/// A subnet argument is not an IPv4 address or CIDR block.
case invalidSubnet(String)
/// A child process could not be started or waited on.
case spawnFailed(command: String, code: Int32)
public var description: String {
switch self {
case .needsPassword(let commands):
return """
this needs administrator rights and stdin is not a terminal, so there is \
nowhere to prompt for a password. Run these by hand, then reboot:
""" + commands.map { "\n " + $0 }.joined()
case .writeFailed(let command, let exitCode):
return "`\(command)` exited \(exitCode)"
case .bundleNotRegistered:
return """
the signed app bundle is not installed, so it cannot be launched as its own \
responsible process — which is the entire point of this method. Install it \
with `make install`, or use --method allowlist instead.
"""
case .probeProducedNoReport:
return "the probe exited without writing a result"
case .invalidSubnet(let entry):
return """
"\(entry)" is not an IPv4 address or CIDR block. macOS silently ignores \
entries it cannot parse, which would leave the allowlist looking configured \
while granting nothing.
"""
case .spawnFailed(let command, let code):
return "could not run \(command): \(String(cString: strerror(code))) (\(code))"
}
}
}
// MARK: - Headless: the subnet allowlist
/// What ``grantViaAllowlist(subnets:allowPasswordPrompt:)`` actually achieved.
public struct AllowlistResult: Sendable {
/// The subnets we asked for.
public let requested: [String]
/// What reading the preferences back afterwards found.
public let observed: LocalNetworkPolicy.Status
/// Whether every requested subnet is now readable on disk.
public var verified: Bool {
requested.allSatisfy(observed.allowlist.contains)
}
}
/// Writes the subnet allowlist, then reads it back to prove it landed.
///
/// The read-back is not ceremony. `sudo defaults write <domain>` resolves
/// the domain relative to whichever `HOME` survived `sudo`'s `env_reset`,
/// which differs between hosts — so the only way to know where the file
/// went is to look. A write that succeeds but leaves nothing readable is
/// reported as unverified rather than as success.
///
/// - Parameters:
/// - subnets: CIDR entries to authorize.
/// - allowPasswordPrompt: When true, `sudo` inherits this process's
/// terminal and may ask for a password. When false it runs `-n` and
/// fails rather than blocking — the right behaviour under `launchd` or
/// in a pipeline.
/// - Returns: The requested subnets and what is now on disk.
public static func grantViaAllowlist(
subnets: [String] = LocalNetworkPolicy.defaultSubnets,
allowPasswordPrompt: Bool
) throws -> AllowlistResult {
for subnet in subnets where !LocalNetworkPolicy.isValidSubnet(subnet) {
throw PermissionError.invalidSubnet(subnet)
}
let commands = LocalNetworkPolicy.writeCommandLines(subnets: subnets)
for (index, arguments) in LocalNetworkPolicy.writeArguments(subnets: subnets).enumerated() {
let sudoArguments = (allowPasswordPrompt ? [] : ["-n"]) + ["/usr/bin/defaults"] + arguments
let exitCode = try runInForeground("/usr/bin/sudo", sudoArguments)
guard exitCode == 0 else {
if !allowPasswordPrompt {
throw PermissionError.needsPassword(commands: commands)
}
throw PermissionError.writeFailed(command: commands[index], exitCode: exitCode)
}
}
return AllowlistResult(requested: subnets, observed: observedStatus())
}
/// The host's allowlist, read with root's privileges when this process's
/// own are not enough.
///
/// `sudo defaults write <domain>` lands in `/var/root/Library/Preferences`
/// on a stock host, and that directory is mode 700 — so the plain read in
/// ``LocalNetworkPolicy/status()`` is refused and a write that worked
/// perfectly looks like it vanished. Re-read the refused candidates as
/// root instead.
///
/// Always `sudo -n`, so this can never turn a status query into a password
/// prompt. Right after a write the credentials are still cached and it
/// simply works; later — after a reboot, say — it fails and the result
/// stays ``LocalNetworkPolicy/Status/isIndeterminate``, which callers
/// report as "cannot tell without root" rather than as "not configured".
public static func observedStatus() -> LocalNetworkPolicy.Status {
let unprivileged = LocalNetworkPolicy.status()
guard !unprivileged.unreadablePaths.isEmpty else { return unprivileged }
var sources: [(path: String, data: Data)] = []
for path in LocalNetworkPolicy.preferenceCandidates() {
if let data = FileManager.default.contents(atPath: path) {
sources.append((path, data))
} else if let data = readAsRoot(path) {
sources.append((path, data))
}
}
let recovered = LocalNetworkPolicy.status(fromContentsOf: sources)
// Nothing came back from the privileged read either: keep the
// unprivileged answer, which still carries why it could not tell.
guard recovered.isConfigured else { return unprivileged }
return recovered
}
/// `sudo -n cat <path>`, or nil if that fails for any reason.
///
/// Both failure modes are ordinary rather than exceptional — the candidate
/// usually does not exist, and `sudo -n` legitimately refuses when no
/// credentials are cached — so stderr is discarded instead of being shown
/// to the operator. `Process` is fine here, unlike in ``runInForeground``:
/// `-n` never touches the terminal.
private static func readAsRoot(_ path: String) -> Data? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
process.arguments = ["-n", "/bin/cat", path]
let output = Pipe()
process.standardOutput = output
process.standardError = FileHandle.nullDevice
process.standardInput = FileHandle.nullDevice
guard (try? process.run()) != nil else { return nil }
let data = output.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0, !data.isEmpty else { return nil }
return data
}
/// Reboots the host. Only ever called from an explicit confirmation — the
/// allowlist is read at boot, so nothing else makes it take effect.
public static func reboot() throws {
_ = try runInForeground("/usr/bin/sudo", ["/sbin/shutdown", "-r", "now"])
}
/// Runs a command with this process's stdio *and its process group*, and
/// returns its exit status.
///
/// The process group is the whole reason this is not `Foundation.Process`.
/// `Process` starts the child as its own process-group leader, so for the
/// controlling terminal the child is a *background* job — and the terminal
/// driver defends itself against those. `sudo`'s `tcsetattr` to turn echo
/// off raises `SIGTTOU` and fails, so the password is typed in the clear;
/// its read of the tty raises `SIGTTIN`, so Return never reaches `sudo` and
/// the line editor just echoes a newline. Both symptoms, one cause.
///
/// `posix_spawn` with no `POSIX_SPAWN_SETPGROUP` leaves the child in our
/// process group, which is the terminal's foreground group, so `sudo` gets
/// the terminal it expects. Stdio is inherited for the same reason it
/// always was: the prompt and any "not in the sudoers file" complaint
/// belong in front of the operator, not captured and paraphrased.
private static func runInForeground(_ executable: String, _ arguments: [String]) throws -> Int32
{
var argv: [UnsafeMutablePointer<CChar>?] = ([executable] + arguments).map { strdup($0) }
argv.append(nil)
var envp: [UnsafeMutablePointer<CChar>?] = ProcessInfo.processInfo.environment.map {
strdup("\($0.key)=\($0.value)")
}
envp.append(nil)
defer {
for pointer in argv { free(pointer) }
for pointer in envp { free(pointer) }
}
var pid: pid_t = 0
let spawned = posix_spawn(&pid, executable, nil, nil, argv, envp)
guard spawned == 0 else {
throw PermissionError.spawnFailed(command: executable, code: spawned)
}
var status: Int32 = 0
while waitpid(pid, &status, 0) < 0 {
guard errno == EINTR else {
throw PermissionError.spawnFailed(command: executable, code: errno)
}
}
// WIFEXITED and friends are C macros, so Swift does not import them.
let terminatingSignal = status & 0x7F
return terminatingSignal == 0 ? (status >> 8) & 0xFF : 128 + terminatingSignal
}
// MARK: - Interactive: the system prompt
/// What a probe observed.
public enum ProbeOutcome: String, Codable, Sendable {
/// Datagrams left the host. Either the app is allowed, or the system is
/// still deciding — macOS drops packets silently while the prompt is up
/// rather than failing the send, so this is "not blocked", not proof.
case permitted
/// Every send came back `EHOSTUNREACH`. That is what Local Network
/// privacy returns when it blocks an app.
case blocked
/// The socket failed for some unrelated reason.
case inconclusive
}
/// A probe result, serialized through a temp file because the probe runs in
/// a separate process launched by LaunchServices.
public struct ProbeReport: Codable, Sendable {
public let outcome: ProbeOutcome
public let detail: String
public init(outcome: ProbeOutcome, detail: String) {
self.outcome = outcome
self.detail = detail
}
}
/// Launches the installed bundle so it provokes the Local Network prompt
/// **as itself**, then reports what the launched process observed.
///
/// The launch is the whole trick. Running this binary from a shell makes
/// Terminal the *responsible process*, so the prompt and the System
/// Settings row name Terminal — and a grant to Terminal does nothing for
/// the LaunchAgent. Going through LaunchServices (`open -b`) makes the app
/// its own responsible process, so the grant attaches to the app's code
/// identity and the agent inherits it.
///
/// That only holds because the bundle is Developer ID signed: a team
/// anchored designated requirement is a stable identity across rebuilds.
/// Under an ad-hoc signature macOS falls back to the Mach-O UUID, which the
/// linker regenerates on every link, and the grant would not survive the
/// next `make install`.
///
/// - Parameter timeout: How long to let the child wait for a verdict. It
/// needs to outlast a human reading the alert.
public static func triggerPrompt(timeout: TimeInterval = 90) throws -> ProbeReport {
let reportURL = FileManager.default.temporaryDirectory
.appendingPathComponent("gmr-probe-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: reportURL) }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = [
"-n", // a fresh instance; an already-running daemon must not be reused
"-b", bundleIdentifier,
"--wait-apps",
"--args", "permissions", "probe",
"--report", reportURL.path,
"--timeout", String(Int(timeout)),
]
// `open` reports "Unable to find application" on stderr; let it through.
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { throw PermissionError.bundleNotRegistered }
guard let data = FileManager.default.contents(atPath: reportURL.path),
let report = try? JSONDecoder().decode(ProbeReport.self, from: data)
else { throw PermissionError.probeProducedNoReport }
return report
}
/// The child side of ``triggerPrompt(timeout:)``: touch the local network
/// and report whether the packets got out.
///
/// Sends to the broadcast address and to mDNS multicast, which is what
/// makes macOS classify this as local-network traffic and raise the prompt.
/// Deliberately does not boot a VM — no guest is needed to trigger the
/// check, and this path therefore needs none of the `NSApplication`
/// plumbing `VZAppRuntime` exists for.
///
/// Retries until `deadline` because the verdict is not synchronous: while
/// the alert is on screen the system neither fails the send nor delivers
/// the packet, so a single attempt cannot distinguish "allowed" from "still
/// asking". Looping until the operator answers is what turns it into a
/// usable signal.
public static func probe(timeout: TimeInterval = 90) async -> ProbeReport {
await activateForPrompt()
let deadline = Date().addingTimeInterval(timeout)
var lastErrno: Int32 = 0
var attempts = 0
repeat {
attempts += 1
guard let code = sendLocalNetworkDatagrams() else {
return ProbeReport(
outcome: .permitted,
detail: attempts == 1
? "local network traffic was not blocked"
: "local network traffic was allowed after \(attempts) attempts"
)
}
lastErrno = code
// Anything other than the privacy filter's answer is a real socket
// problem; retrying will not change it.
guard code == EHOSTUNREACH else {
return ProbeReport(
outcome: .inconclusive,
detail: "socket error \(code): \(describeErrno(code))"
)
}
// Deliberately not Thread.sleep: activateForPrompt just put this
// process in the foreground, and a main thread wedged in a sleep is
// a process macOS will show as unresponsive while the alert it is
// waiting on is on screen.
try? await Task.sleep(nanoseconds: 1_000_000_000)
} while Date() < deadline
return ProbeReport(
outcome: .blocked,
detail: "every send over \(attempts) attempts returned EHOSTUNREACH (errno \(lastErrno))"
)
}
/// Sends one datagram to the broadcast address and one to mDNS multicast.
///
/// - Returns: `nil` if either got out, otherwise the last `errno`.
private static func sendLocalNetworkDatagrams() -> Int32? {
// Port 9 is discard; 5353 is mDNS. Nothing has to be listening — the
// privacy filter makes its decision on the send, not on a reply.
let targets: [(address: String, port: UInt16)] = [
("255.255.255.255", 9),
("224.0.0.251", 5353),
]
var lastErrno: Int32 = EINVAL
for target in targets {
let handle = socket(AF_INET, SOCK_DGRAM, 0)
guard handle >= 0 else {
lastErrno = errno
continue
}
defer { close(handle) }
var enable: Int32 = 1
setsockopt(handle, SOL_SOCKET, SO_BROADCAST, &enable, socklen_t(MemoryLayout<Int32>.size))
var destination = sockaddr_in()
destination.sin_family = sa_family_t(AF_INET)
destination.sin_port = target.port.bigEndian
destination.sin_addr.s_addr = inet_addr(target.address)
let payload: [UInt8] = [0]
let sent = withUnsafePointer(to: &destination) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in
sendto(handle, payload, payload.count, 0, address, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
if sent >= 0 { return nil }
lastErrno = errno
}
return lastErrno
}
/// `strerror`, with the optionality unwrapped.
private static func describeErrno(_ code: Int32) -> String {
guard let text = strerror(code) else { return "unknown error" }
return String(cString: text)
}
/// Brings the probe process forward so the system alert has a frontmost app
/// to attach to.
///
/// `LSUIElement` in `Info.plist` would otherwise leave this at `.accessory`.
/// The daemon wants that — it goes further and sets `.prohibited` — but a
/// prompt nobody can see is the exact failure this command exists to fix,
/// so the probe opts back in. It starts no VM, so it is not bound by the
/// activation policy `VZAppRuntime` needs.
@MainActor
private static func activateForPrompt() {
let app = NSApplication.shared
app.setActivationPolicy(.regular)
app.activate(ignoringOtherApps: true)
}
}
+89 -6
View File
@@ -50,7 +50,7 @@ public struct LiveVM: Sendable {
/// ///
/// `ensureFreeSpace` → `cloneImage(named:slotMAC:)` → ``VMInstance/start(options:)`` /// `ensureFreeSpace` → `cloneImage(named:slotMAC:)` → ``VMInstance/start(options:)``
/// → poll `/var/db/dhcpd_leases` for the slot MAC until `bootTimeout` → /// → poll `/var/db/dhcpd_leases` for the slot MAC until `bootTimeout` →
/// ``waitForSSH(host:port:username:password:timeout:pollInterval:)`` → write the /// ``waitForSSH(host:port:username:password:timeout:pollInterval:reportInterval:onAttemptFailure:)`` → write the
/// registration token into a guest file with mode `0600` → over SSH: /// registration token into a guest file with mode `0600` → over SSH:
/// ///
/// ```sh /// ```sh
@@ -105,6 +105,15 @@ public actor Orchestrator {
/// The supervising task per slot: clone → boot → register → run → teardown. /// The supervising task per slot: clone → boot → register → run → teardown.
private var slotTasks: [Int: Task<Void, Never>] = [:] private var slotTasks: [Int: Task<Void, Never>] = [:]
/// Why a slot's supervising task was cancelled, left for that task to find.
///
/// `Task.cancel()` carries no payload and `CancellationError` no detail, so
/// a lifecycle that catches one knows only *that* it was stopped. Everything
/// worth reading — "boot timeout: provisioning for 312s (limit 300s)" — is
/// known only to the canceller. Without this hand-off the operator sees
/// `reason=cancelled`, which names the mechanism and hides the cause.
private var slotCancelReasons: [Int: String] = [:]
/// The "the guest stopped on its own" watcher per slot. /// The "the guest stopped on its own" watcher per slot.
private var deathWatchTasks: [Int: Task<Void, Never>] = [:] private var deathWatchTasks: [Int: Task<Void, Never>] = [:]
@@ -252,6 +261,7 @@ public actor Orchestrator {
// let them run their own teardown; whatever they miss we clean up below. // let them run their own teardown; whatever they miss we clean up below.
let tasks = slotTasks let tasks = slotTasks
slotTasks.removeAll() slotTasks.removeAll()
for (slot, _) in tasks { slotCancelReasons[slot] = "shutting down" }
for (_, task) in tasks { task.cancel() } for (_, task) in tasks { task.cancel() }
for (_, task) in tasks { await task.value } for (_, task) in tasks { await task.value }
@@ -307,6 +317,9 @@ public actor Orchestrator {
// are about to kill, so it would otherwise clear that entry only // are about to kill, so it would otherwise clear that entry only
// after the boot had already been refused. // after the boot had already been refused.
if let task = slotTasks.removeValue(forKey: slot) { if let task = slotTasks.removeValue(forKey: slot) {
// Before the cancel, not after: the task may reach its
// `catch` the instant it is cancelled.
slotCancelReasons[slot] = reason
task.cancel() task.cancel()
// Its own teardown runs to completion here, which also means // Its own teardown runs to completion here, which also means
// it cannot race a successor booted later in this pass. // it cannot race a successor booted later in this pass.
@@ -404,6 +417,9 @@ public actor Orchestrator {
let generation = (slotGeneration[slot] ?? 0) + 1 let generation = (slotGeneration[slot] ?? 0) + 1
slotGeneration[slot] = generation slotGeneration[slot] = generation
// Taken alongside the `Date()` handed to the planner, so the lifecycle
// can measure against the same deadline the planner will enforce.
let provisioningStarted = ContinuousClock.now
state = SchedulerCore.markProvisioning(state: state, slot: slot, jobHint: jobHint, now: Date()) state = SchedulerCore.markProvisioning(state: state, slot: slot, jobHint: jobHint, now: Date())
logger.info( logger.info(
@@ -417,7 +433,13 @@ public actor Orchestrator {
slotTasks[slot] = Task { [weak self] in slotTasks[slot] = Task { [weak self] in
guard let self else { return } guard let self else { return }
await self.runSlotLifecycle(slot: slot, jobHint: jobHint, runnerName: runnerName, generation: generation) await self.runSlotLifecycle(
slot: slot,
jobHint: jobHint,
runnerName: runnerName,
generation: generation,
provisioningStarted: provisioningStarted
)
} }
} }
@@ -425,11 +447,40 @@ public actor Orchestrator {
/// ///
/// Every failure path funnels into the same teardown, because a slot that is /// Every failure path funnels into the same teardown, because a slot that is
/// neither live nor idle is a slot leaked for the process's lifetime. /// neither live nor idle is a slot leaked for the process's lifetime.
private func runSlotLifecycle(slot: Int, jobHint: Int64, runnerName: String, generation: Int) async { ///
/// - Parameter provisioningStarted: When the *planner's* boot deadline began
/// ticking — earlier than this function's own first instruction. Used to
/// budget the readiness waits against the deadline that will actually be
/// enforced rather than against a fresh copy of it.
private func runSlotLifecycle(
slot: Int,
jobHint: Int64,
runnerName: String,
generation: Int,
provisioningStarted: ContinuousClock.Instant
) async {
let bootTimeout = Duration.seconds(max(30, config.scheduler.bootTimeoutSeconds)) let bootTimeout = Duration.seconds(max(30, config.scheduler.bootTimeoutSeconds))
let jobTimeout = Duration.seconds(max(60, config.scheduler.jobTimeoutMinutes * 60)) let jobTimeout = Duration.seconds(max(60, config.scheduler.jobTimeoutMinutes * 60))
var teardownReason = "job finished" var teardownReason = "job finished"
// Both readiness waits below are already supervised by the planner's
// boot deadline, which started ticking at `provisioningStarted` — before
// the clone, the boot and the DHCP lease had spent any of it. Handing
// either wait the full `bootTimeout` puts its deadline strictly *after*
// the planner's, so the planner always wins the race: this task is
// cancelled mid-wait and the specific error the wait was about to throw
// ("ssh on 192.168.65.233:22 after 41 attempts; last error: …") is
// discarded in favour of a bare cancellation. Budget from what is left
// and the wait gets to speak first.
func remainingBootBudget() -> Duration {
let spent = ContinuousClock.now - provisioningStarted
// Landing a little before the planner, so its next tick finds the
// slot already failing for a stated reason. The floor keeps an
// already-overrun budget from collapsing to zero attempts, which
// would trade one useless message for another.
return max(.seconds(15), bootTimeout - spent - .seconds(5))
}
do { do {
let mac = try store.macAddress(forSlot: slot, slotCount: slotCount) let mac = try store.macAddress(forSlot: slot, slotCount: slotCount)
// Whatever lease this MAC already holds belongs to the *previous* // Whatever lease this MAC already holds belongs to the *previous*
@@ -454,15 +505,40 @@ public actor Orchestrator {
await self?.vmStoppedUnexpectedly(slot: slot, generation: generation, reason: reason) await self?.vmStoppedUnexpectedly(slot: slot, generation: generation, reason: reason)
} }
let ip = try await waitForLease(mac: mac, timeout: bootTimeout, replacing: priorLease) let ip = try await waitForLease(
mac: mac,
timeout: remainingBootBudget(),
replacing: priorLease
)
live[slot]?.ipAddress = ip live[slot]?.ipAddress = ip
logger.info("guest leased address", metadata: ["slot": .stringConvertible(slot), "ip": .string(ip)]) logger.info("guest leased address", metadata: ["slot": .stringConvertible(slot), "ip": .string(ip)])
// A `Logger` is a value type, so the callback below gets its own
// copy and never touches the actor — which is what lets it be a
// plain synchronous closure called from inside the poll loop.
let log = logger
try await waitForSSH( try await waitForSSH(
host: ip, host: ip,
username: config.guest.username, username: config.guest.username,
password: config.guest.password, password: config.guest.password,
timeout: bootTimeout timeout: remainingBootBudget(),
onAttemptFailure: { attempt in
// A guest sharing a host with other Virtualization guests
// can take minutes to start `sshd`. Without this the wait is
// indistinguishable from a hang: the log goes quiet between
// "guest leased address" and teardown, which is exactly the
// window an operator most wants to see into.
log.info(
"waiting for guest ssh",
metadata: [
"slot": .stringConvertible(slot),
"host": .string(ip),
"attempt": .stringConvertible(attempt.attempt),
"elapsed": .string("\(attempt.elapsed.components.seconds)s"),
"error": .string(attempt.error),
]
)
}
) )
let token = try await registrationToken() let token = try await registrationToken()
@@ -511,7 +587,9 @@ public actor Orchestrator {
) )
} }
} catch is CancellationError { } catch is CancellationError {
teardownReason = "cancelled" // Whoever cancelled us knows why; `CancellationError` does not.
// Falling back to "cancelled" only when nobody left a note.
teardownReason = slotCancelReasons[slot] ?? "cancelled"
} catch { } catch {
teardownReason = "\(error)" teardownReason = "\(error)"
logger.error( logger.error(
@@ -539,6 +617,10 @@ public actor Orchestrator {
state = SchedulerCore.releaseJob(state: state, jobID: jobHint) state = SchedulerCore.releaseJob(state: state, jobID: jobHint)
} }
// A note left for a cancel that arrived after the lifecycle had already
// finished on its own would otherwise be read by the *next* occupant of
// this slot, mislabelling its teardown.
slotCancelReasons[slot] = nil
slotTasks[slot] = nil slotTasks[slot] = nil
} }
@@ -551,6 +633,7 @@ public actor Orchestrator {
"guest stopped unexpectedly", "guest stopped unexpectedly",
metadata: ["slot": .stringConvertible(slot), "reason": .string("\(reason)")] metadata: ["slot": .stringConvertible(slot), "reason": .string("\(reason)")]
) )
slotCancelReasons[slot] = "guest stopped: \(reason)"
slotTasks[slot]?.cancel() slotTasks[slot]?.cancel()
await teardownSlot(slot, reason: "guest stopped: \(reason)") await teardownSlot(slot, reason: "guest stopped: \(reason)")
} }
+59 -18
View File
@@ -110,9 +110,10 @@ struct DaemonCommand: AsyncParsableCommand {
// Expected on shutdown. // Expected on shutdown.
} catch { } catch {
logger.critical("daemon stopped", metadata: ["error": .string("\(error)")]) logger.critical("daemon stopped", metadata: ["error": .string("\(error)")])
// Fully qualified: inside a ParsableCommand a bare `exit` // Not `MainActor.run`: the main actor is parked inside
// resolves to ParsableCommand.exit(withError:). // `app.run()` for the life of the process, so hopping onto
await MainActor.run { Foundation.exit(1) } // it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
} }
} }
) )
@@ -123,18 +124,40 @@ struct DaemonCommand: AsyncParsableCommand {
/// run loop it requires, while the real work runs in a `Task`. /// run loop it requires, while the real work runs in a `Task`.
/// ///
/// Shared by `daemon` and `vm boot`: any command that starts a VM needs this. /// Shared by `daemon` and `vm boot`: any command that starts a VM needs this.
@MainActor
enum VZAppRuntime { enum VZAppRuntime {
/// Signal sources have to outlive the call that creates them or they are /// Signal sources have to outlive the call that creates them or they are
/// cancelled on deinit and the signals go nowhere. /// cancelled on deinit and the signals go nowhere.
private static var signalSources: [DispatchSourceSignal] = [] private nonisolated(unsafe) static var signalSources: [DispatchSourceSignal] = []
private static var isTerminating = false private nonisolated(unsafe) static var isTerminating = false
private static let stateLock = NSLock()
/// Signals land here rather than on `.main`. See ``run(onSignal:body:)``.
private static let signalQueue = DispatchQueue(
label: "xyz.blakeslee.gitea-macos-vm-orchestrator.signals")
/// Starts the run loop and runs `body` alongside it. Never returns. /// Starts the run loop and runs `body` alongside it. Never returns.
/// ///
/// ## Nothing here may touch the main queue
///
/// This is reached from Swift's async `main`, so the frame that calls
/// `app.run()` is *itself* a block executing on the main dispatch queue —
/// and it never returns. libdispatch will not re-enter a serial queue that
/// already has a block in flight, so from this moment the main queue is
/// closed for business: a plain `Task { }` inheriting a `@MainActor`
/// context, a `DispatchSource` handler on `.main`, or an
/// `await MainActor.run { … }` all enqueue work that can never be drained.
///
/// The symptom is exact and was reported as a hang in `image build`: a live
/// run loop, zero CPU, and no output past the last line printed before this
/// call — `body` had been enqueued behind `app.run()` and never got a first
/// tick. Hence `Task.detached`, a private signal queue, and ``exit(_:)``
/// called straight from whichever thread reaches it. A normal AppKit app
/// does not hit this because its `main()` is not a main-queue block.
///
/// - Parameters: /// - Parameters:
/// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting. /// - onSignal: Cleanup to perform on `SIGINT`/`SIGTERM` before exiting.
/// - body: The work to run. When it returns, the process exits zero. /// - body: The work to run. When it returns, the process exits zero.
@MainActor
static func run( static func run(
onSignal: @escaping @Sendable () async -> Void, onSignal: @escaping @Sendable () async -> Void,
body: @escaping @Sendable () async -> Void body: @escaping @Sendable () async -> Void
@@ -149,30 +172,48 @@ enum VZAppRuntime {
// DispatchSourceSignal only observes; the default disposition still // DispatchSourceSignal only observes; the default disposition still
// kills the process unless it is ignored first. // kills the process unless it is ignored first.
signal(signalNumber, SIG_IGN) signal(signalNumber, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: signalQueue)
source.setEventHandler { source.setEventHandler {
Task { @MainActor in guard beginTerminating() else { return }
guard !isTerminating else { return } Task.detached {
isTerminating = true
CLI.note("received signal; shutting down…") CLI.note("received signal; shutting down…")
await onSignal() await onSignal()
NSApp.terminate(nil) flushAndExit(0)
exit(0)
} }
} }
source.resume() source.resume()
stateLock.lock()
signalSources.append(source) signalSources.append(source)
stateLock.unlock()
} }
Task { // Detached on purpose: an inheriting `Task { }` would be queued behind
// the `app.run()` below and never start. See the note above.
Task.detached {
await body() await body()
await MainActor.run { flushAndExit(0)
NSApp.terminate(nil)
exit(0)
}
} }
app.run() app.run()
exit(0) flushAndExit(0)
}
/// Wins the race to shut down, exactly once.
private static func beginTerminating() -> Bool {
stateLock.lock()
defer { stateLock.unlock() }
guard !isTerminating else { return false }
isTerminating = true
return true
}
/// Exits from any thread, without hopping to the unusable main actor.
///
/// `NSApp.terminate(nil)` is deliberately not called: it requires the main
/// actor, which is exactly what is not available here.
nonisolated static func flushAndExit(_ code: Int32) -> Never {
fflush(stdout)
fflush(stderr)
exit(code)
} }
} }
+101 -21
View File
@@ -35,7 +35,13 @@ struct ImageCommand: AsyncParsableCommand {
var name: String = "default" var name: String = "default"
/// A local `.ipsw`; omit to download the latest supported image. /// A local `.ipsw`; omit to download the latest supported image.
@Option(name: .long, help: "Path to a local .ipsw (default: download the latest supported).") ///
/// Resolved through ``PathResolution`` so it works whether or not the
/// shell got to the glob first: `--ipsw '~/Downloads/UniversalMac_27*.ipsw'`
/// and the unquoted form both land on the same file.
@Option(
name: .long,
help: "Path to a local .ipsw; may be a glob (default: download the latest supported).")
var ipsw: String? var ipsw: String?
/// Nominal guest disk size, overriding `guest.diskGB`. /// Nominal guest disk size, overriding `guest.diskGB`.
@@ -44,6 +50,12 @@ struct ImageCommand: AsyncParsableCommand {
func run() async throws { func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose) CLI.bootstrapLogging(verbose: options.verbose)
// Before anything else touches the disk: an unresolvable --ipsw is
// an argument error, and an argument error should not first make the
// operator wait on a config load and a free-space check.
let resolvedIPSW = try ipsw.map { try PathResolution.resolve($0, label: "--ipsw") }
var config = try options.loadConfig() var config = try options.loadConfig()
if let diskGB { if let diskGB {
config.guest.diskGB = diskGB config.guest.diskGB = diskGB
@@ -52,7 +64,12 @@ struct ImageCommand: AsyncParsableCommand {
let store = VMStore(config: config) let store = VMStore(config: config)
try store.ensureLayout() try store.ensureLayout()
if try store.image(named: name) != nil { // Only a *finished* image blocks a rebuild. An installed but
// unprovisioned bundle is an hour of work that `ImageBuilder.build`
// knows how to resume, so it must get the chance to say so.
if let existing = try store.image(named: name),
(try? existing.loadConfig())?.provisioned == true
{
throw ValidationError( throw ValidationError(
"image '\(name)' already exists — delete it first with `image delete \(name)`" "image '\(name)' already exists — delete it first with `image delete \(name)`"
) )
@@ -64,7 +81,7 @@ struct ImageCommand: AsyncParsableCommand {
let printer = ProgressPrinter() let printer = ProgressPrinter()
let builder = ImageBuilder(store: store) let builder = ImageBuilder(store: store)
let imageName = name let imageName = name
let ipswPath = ipsw let ipswPath = resolvedIPSW
let frozenConfig = config let frozenConfig = config
// `image build` runs `VZMacOSInstaller` and then boots the guest, so // `image build` runs `VZMacOSInstaller` and then boots the guest, so
@@ -79,16 +96,20 @@ struct ImageCommand: AsyncParsableCommand {
name: imageName, name: imageName,
ipswPath: ipswPath, ipswPath: ipswPath,
config: frozenConfig, config: frozenConfig,
progress: { stage in printer.update(ImageCommand.describe(stage)) } progress: { stage in ImageCommand.report(stage, to: printer) }
) )
} catch { } catch {
printer.finish() printer.finish()
CLI.error("\(error)") CLI.error("\(error)")
// Fully qualified: inside a ParsableCommand a bare `exit` // Not `MainActor.run`: the main actor is parked inside
// resolves to ParsableCommand.exit(withError:). // `app.run()` for the life of the process, so hopping onto
await MainActor.run { Foundation.exit(1) } // it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
} }
printer.finish("done") // Just seals the line: the builder's own `.done` stage has
// already printed it, and saying it twice down a pipe reads
// like something ran twice.
printer.finish()
print("built image '\(imageName)'") print("built image '\(imageName)'")
print("next: gitea-macos-runner vm boot --image \(imageName)") print("next: gitea-macos-runner vm boot --image \(imageName)")
@@ -201,20 +222,28 @@ struct ImageCommand: AsyncParsableCommand {
/// Optional Xcode `.xip` to install into the guest. Adds tens of /// Optional Xcode `.xip` to install into the guest. Adds tens of
/// gigabytes; omitted by default. /// gigabytes; omitted by default.
@Option(name: .customLong("xcode-xip"), help: "Path to an Xcode .xip to install into the guest.") @Option(
name: .customLong("xcode-xip"),
help: "Path to an Xcode .xip to install into the guest; may be a glob.")
var xcodeXIP: String? var xcodeXIP: String?
func run() async throws { func run() async throws {
CLI.bootstrapLogging(verbose: options.verbose) CLI.bootstrapLogging(verbose: options.verbose)
// Same treatment as `image build --ipsw`, and for the same reason:
// this is a long path to a big file that people reach for with a
// glob. Resolved first so a bad one costs nothing.
let resolvedXIP = try xcodeXIP.map { try PathResolution.resolve($0, label: "--xcode-xip") }
if let resolvedXIP, !FileManager.default.fileExists(atPath: resolvedXIP) {
throw ValidationError("no file at \(resolvedXIP)")
}
let config = try options.loadConfig() let config = try options.loadConfig()
let store = VMStore(config: config) let store = VMStore(config: config)
guard try store.image(named: name) != nil else { guard try store.image(named: name) != nil else {
throw ValidationError("no image named '\(name)'") throw ValidationError("no image named '\(name)'")
} }
if let xcodeXIP, !FileManager.default.fileExists(atPath: RunnerConfig.expandTilde(xcodeXIP)) {
throw ValidationError("no file at \(RunnerConfig.expandTilde(xcodeXIP))")
}
CLI.note("provisioning base image '\(name)' in place — stop the daemon before doing this") CLI.note("provisioning base image '\(name)' in place — stop the daemon before doing this")
@@ -222,7 +251,7 @@ struct ImageCommand: AsyncParsableCommand {
let builder = ImageBuilder(store: store) let builder = ImageBuilder(store: store)
let imageName = name let imageName = name
let frozenConfig = config let frozenConfig = config
let xipPath = xcodeXIP.map(RunnerConfig.expandTilde) let xipPath = resolvedXIP
// Boots the image to run provision.sh in it, so it needs the run // Boots the image to run provision.sh in it, so it needs the run
// loop for exactly the reason `image build` does. // loop for exactly the reason `image build` does.
@@ -234,14 +263,15 @@ struct ImageCommand: AsyncParsableCommand {
name: imageName, name: imageName,
config: frozenConfig, config: frozenConfig,
xcodeXIPPath: xipPath, xcodeXIPPath: xipPath,
progress: { stage in printer.update(ImageCommand.describe(stage)) } progress: { stage in ImageCommand.report(stage, to: printer) }
) )
} catch { } catch {
printer.finish() printer.finish()
CLI.error("\(error)") CLI.error("\(error)")
// Fully qualified: inside a ParsableCommand a bare `exit` // Not `MainActor.run`: the main actor is parked inside
// resolves to ParsableCommand.exit(withError:). // `app.run()` for the life of the process, so hopping onto
await MainActor.run { Foundation.exit(1) } // it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
} }
printer.finish("done") printer.finish("done")
print("provisioned image '\(imageName)'") print("provisioned image '\(imageName)'")
@@ -250,19 +280,69 @@ struct ImageCommand: AsyncParsableCommand {
} }
} }
/// Routes a stage to the progress printer.
///
/// Notes get a line of their own: they are the reason the operator is still
/// watching, and a status line that is about to be overwritten is no place
/// to put "this may be a truncated download".
static func report(_ stage: ImageBuildStage, to printer: ProgressPrinter) {
if case .note(let text) = stage {
printer.line(text)
} else {
printer.update(describe(stage), group: group(of: stage))
}
}
/// The stage a status line belongs to, ignoring its varying payload.
///
/// Two lines share a group exactly when one is meant to overwrite the
/// other. Crossing a group boundary seals the previous line instead, which
/// is why `installing macOS … 100%` survives into scrollback rather than
/// being replaced by `first boot + guest provisioning…`.
static func group(of stage: ImageBuildStage) -> String {
switch stage {
case .downloadingIPSW: return "download"
case .preparing: return "preparing"
case .loadingRestoreImage: return "loading"
case .creatingBundle: return "bundle"
case .note: return "note"
case .installing: return "install"
case .firstBoot: return "firstBoot"
// Each provisioning step is its own headline — "installing Node.js"
// should not erase "downloading gitea-runner" — but a step that carries
// a live percentage keeps rewriting one line rather than scrolling a
// hundred of them, so only the part before the payload identifies it.
case .provisioning(let step): return "provisioning:\(Self.stableHead(of: step))"
case .finalizing: return "finalizing"
case .done: return "done"
}
}
/// The fixed part of a status line: everything before the two-space run that
/// separates a headline from its payload, following the same convention as
/// `installing macOS [====]`. A step with no payload is its own head.
static func stableHead(of step: String) -> String {
guard let separator = step.range(of: " ") else { return step }
return String(step[step.startIndex..<separator.lowerBound])
}
/// Renders a build stage as one status line. /// Renders a build stage as one status line.
static func describe(_ stage: ImageBuildStage) -> String { static func describe(_ stage: ImageBuildStage) -> String {
switch stage { switch stage {
case .downloadingIPSW(let fraction): case .downloadingIPSW(let fraction):
return "downloading IPSW " + CLI.progressBar(fraction) return "downloading IPSW " + CLI.progressBar(fraction)
case .preparing: case .preparing:
return "preparing" return "resolving restore image…"
case .creatingBundle: case .loadingRestoreImage:
return "creating bundle" return "loading restore image metadata…"
case .creatingBundle(let diskGB):
return "creating VM bundle (disk \(diskGB) GB)…"
case .note(let text):
return text
case .installing(let fraction): case .installing(let fraction):
return "installing macOS " + CLI.progressBar(fraction) return "installing macOS " + CLI.progressBar(fraction)
case .firstBoot: case .firstBoot:
return "first boot (Setup Assistant)" return "first boot + guest provisioning…"
case .provisioning(let step): case .provisioning(let step):
return "provisioning: \(step)" return "provisioning: \(step)"
case .finalizing: case .finalizing:
@@ -0,0 +1,282 @@
import ArgumentParser
import Foundation
import RunnerCore
import RunnerHost
/// `gitea-macos-runner permissions …` — inspect and grant the macOS 15+ Local
/// Network access the runner needs to reach its guests.
///
/// This exists because the alternative was a paragraph of documentation asking
/// the operator to paste two `sudo defaults write` lines and reboot. That is
/// the single most common way a freshly installed runner fails — every guest
/// boots, no job ever starts, and the only symptom is `No route to host`.
struct PermissionsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "permissions",
abstract: "Inspect and grant the macOS Local Network access guests are reached over.",
discussion: """
macOS 15 and newer filter local-network traffic per app. When the runner is \
blocked the connection fails with "No route to host", which looks exactly \
like a guest that is off the network — so this is worth checking before \
debugging anything else.
`permissions grant` offers two routes. The default subnet allowlist is \
deterministic and applies to every process, but is read at boot, so it \
needs a reboot. `--method prompt` provokes the real system prompt and \
applies immediately, but needs a GUI session and an installed app bundle.
""",
subcommands: [Status.self, Grant.self, Probe.self]
)
/// `permissions status` — what is configured, and what to do about it.
struct Status: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "status",
abstract: "Report whether local network access is configured."
)
@OptionGroup var options: GlobalOptions
func run() async throws {
// The same two checks `doctor` runs, rendered the same way. Code
// identity belongs here because it decides whether an interactive
// grant survives the next build — an ad-hoc signature makes
// --method prompt a waste of the operator's time.
print(Doctor.format([
Doctor.localNetworkNote(),
Doctor.checkCodeSignature(),
]))
let status = LocalNetworkPermission.observedStatus()
if !status.sourcePaths.isEmpty {
print("")
for path in status.sourcePaths {
print("allowlist read from: \(path)")
}
}
if status.isIndeterminate {
print("")
print("The allowlist lives in root's preferences, which only root can read, so")
print("this cannot tell whether it is already set. For a definitive answer:")
print(" sudo gitea-macos-runner permissions status")
}
guard !status.coversGuestRange else { return }
print("")
// Not visible is not the same as not set, and an unconfigured host
// looks identical to a configured one from an ordinary login — so
// offer the commands without asserting anything is broken.
print(status.isIndeterminate ? "if it is not set, either of these sets it:" : "to fix:")
print(" gitea-macos-runner permissions grant # subnet allowlist, needs a reboot")
print(" gitea-macos-runner permissions grant --method prompt # system prompt, takes effect at once")
}
}
/// `permissions grant` — actually configure it.
struct Grant: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "grant",
abstract: "Grant local network access to the guest subnets.",
discussion: """
The default `allowlist` method writes com.apple.network.local-network with \
sudo, so it will ask for your password, and the values are only read at \
boot — nothing changes until you reboot.
`--method prompt` instead launches the installed app bundle through \
LaunchServices so it becomes its own responsible process, and provokes the \
system prompt as *this app* rather than as Terminal. That distinction is \
the whole point: a grant given to Terminal does not carry over to the \
LaunchAgent. It takes effect immediately, but needs `make install` to have \
run and a GUI session to show the alert in.
"""
)
@OptionGroup var options: GlobalOptions
@Option(name: .long, help: "How to grant it: allowlist (default) or prompt.")
var method: LocalNetworkPermission.Method = .allowlist
@Option(
name: .long,
parsing: .singleValue,
help: ArgumentHelp(
"Subnet to authorize, repeatable. Defaults to all of RFC 1918.",
valueName: "cidr"
))
var subnet: [String] = []
@Flag(
inversion: .prefixedNo,
help: "Reboot when the allowlist is written. Default: ask, when on a terminal.")
var reboot: Bool?
func run() async throws {
try LocalNetworkGrantFlow.run(method: method, subnets: subnet, reboot: reboot)
}
}
/// `permissions probe` — the child half of `grant --method prompt`.
///
/// Hidden because it is not something to run directly: invoked from a shell
/// it is attributed to Terminal, which is precisely the attribution the
/// prompt method exists to avoid. It is only meaningful when LaunchServices
/// started it.
struct Probe: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "probe",
abstract: "Internal: touch the local network and report whether it was blocked.",
shouldDisplay: false
)
@Option(name: .long, help: "Where to write the JSON result.")
var report: String?
@Option(name: .long, help: "Seconds to wait for a verdict.")
var timeout: Int = 90
func run() async throws {
let result = await LocalNetworkPermission.probe(timeout: TimeInterval(timeout))
guard let report else {
print("\(result.outcome.rawValue): \(result.detail)")
return
}
try JSONEncoder().encode(result).write(to: URL(fileURLWithPath: report))
}
}
}
extension LocalNetworkPermission.Method: ExpressibleByArgument {}
/// The operator-facing grant flow, shared by `permissions grant` and the
/// `service install` hook.
///
/// It lives outside both so `service install` does not have to construct
/// another command's `ParsableCommand` and mutate its parsed properties, which
/// works only by accident of how ArgumentParser synthesizes initializers.
enum LocalNetworkGrantFlow {
/// Runs one grant, end to end, printing what happened.
///
/// - Parameters:
/// - method: Allowlist or system prompt.
/// - subnets: Empty means the RFC 1918 default. Allowlist only.
/// - reboot: `nil` asks, when there is a terminal to ask on.
static func run(
method: LocalNetworkPermission.Method,
subnets: [String] = [],
reboot: Bool? = nil
) throws {
switch method {
case .allowlist: try grantAllowlist(subnets: subnets, reboot: reboot)
case .prompt: try grantByPrompt(subnets: subnets)
}
}
private static func grantAllowlist(subnets requested: [String], reboot: Bool?) throws {
let subnets = requested.isEmpty ? LocalNetworkPolicy.defaultSubnets : requested
let interactive = isatty(fileno(stdin)) == 1
CLI.note("authorizing \(subnets.joined(separator: ", ")) for local network access")
if interactive {
CLI.note("this needs administrator rights; sudo may ask for your password")
}
let result: LocalNetworkPermission.AllowlistResult
do {
result = try LocalNetworkPermission.grantViaAllowlist(
subnets: subnets, allowPasswordPrompt: interactive)
} catch let error as LocalNetworkPermission.PermissionError {
CLI.error("\(error)")
throw ExitCode(1)
}
// `defaults` reports success regardless of which preferences directory
// the write landed in, so report what was read back rather than what
// was asked for. See LocalNetworkPermission.grantViaAllowlist.
if result.verified {
print("granted: \(result.observed.allowlist.joined(separator: ", "))")
for path in result.observed.sourcePaths {
print("written to: \(path)")
}
if !result.observed.coversGuestRange {
CLI.note("""
warning: none of these cover the whole guest range (192.168.64.0/18), \
so guests will still be blocked once the NAT subnet shifts
""")
}
} else if result.observed.isIndeterminate {
// The write succeeded but there is no way to look: the allowlist
// lands in root's preferences, and this host does not keep sudo
// credentials cached long enough for the read-back to use them.
// Unknown is not failure — say so plainly rather than either
// claiming success or crying wolf.
CLI.note("""
wrote \(subnets.joined(separator: ", ")), but could not read it back to \
confirm — that needs administrator rights this process no longer holds. \
Check it with: sudo defaults read \(LocalNetworkPolicy.domain)
""")
} else {
CLI.error("""
the write reported success but the values could not be read back. \
Check by hand: sudo defaults read \(LocalNetworkPolicy.domain)
""")
throw ExitCode(1)
}
print("")
print("This is read at boot, so it does nothing until the host reboots.")
guard reboot ?? CLI.confirm("reboot now?") else {
CLI.note("not rebooting; run `sudo shutdown -r now` when convenient")
return
}
try LocalNetworkPermission.reboot()
}
private static func grantByPrompt(subnets: [String]) throws {
guard subnets.isEmpty else {
CLI.error("--subnet applies to --method allowlist only; the system prompt is not per-subnet")
throw ExitCode(2)
}
CLI.note("launching the app bundle so the prompt is attributed to it, not to Terminal")
CLI.note("answer \"Allow\" in the alert that appears")
let report: LocalNetworkPermission.ProbeReport
do {
report = try LocalNetworkPermission.triggerPrompt()
} catch let error as LocalNetworkPermission.PermissionError {
CLI.error("\(error)")
throw ExitCode(1)
}
switch report.outcome {
case .permitted:
print("local network access is not blocked (\(report.detail))")
print("")
print("""
This applies immediately — no reboot. It is tied to the app's code \
identity, so it survives rebuilds only while the bundle keeps a stable \
Developer ID signature; `permissions status` reports that.
""")
case .blocked:
CLI.error("still blocked after the prompt (\(report.detail))")
CLI.note("""
Either the alert was declined, or macOS already has a decision on file for \
this app — it does not ask twice, and there is no way to reset one. Look in \
System Settings > Privacy & Security > Local Network: if there is a row for \
Gitea macOS Runner, switch it on.
""")
CLI.note("""
Otherwise use the allowlist, which needs no prompt at all: \
gitea-macos-runner permissions grant
""")
throw ExitCode(1)
case .inconclusive:
CLI.error("could not tell: \(report.detail)")
throw ExitCode(1)
}
}
}
@@ -21,7 +21,7 @@ struct ServiceCommand: AsyncParsableCommand {
struct Install: AsyncParsableCommand { struct Install: AsyncParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
commandName: "install", commandName: "install",
abstract: "Write ~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist and load it.", abstract: "Write ~/Library/LaunchAgents/\(LaunchdService.label).plist and load it.",
discussion: """ discussion: """
Points the agent at the installed, signed .app bundle — not at a bare \ Points the agent at the installed, signed .app bundle — not at a bare \
binary. The com.apple.security.virtualization entitlement only survives \ binary. The com.apple.security.virtualization entitlement only survives \
@@ -35,6 +35,16 @@ struct ServiceCommand: AsyncParsableCommand {
@Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).") @Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).")
var executable: String? var executable: String?
/// How to configure Local Network access, if it is not already.
///
/// Unset means "decide at run time": ask on a terminal, skip with a
/// pointer otherwise. `none` suppresses the question outright, for a
/// scripted install that has its own arrangements.
@Option(
name: .customLong("grant-local-network"),
help: "Configure macOS Local Network access during install: allowlist, prompt, or none.")
var grantLocalNetwork: LocalNetworkGrantChoice?
func run() async throws { func run() async throws {
let executablePath = executable ?? LaunchdService.defaultExecutablePath let executablePath = executable ?? LaunchdService.defaultExecutablePath
@@ -46,14 +56,94 @@ struct ServiceCommand: AsyncParsableCommand {
CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed") CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed")
} }
// Done before install (which also does it) purely so the operator
// is told: an agent silently vanishing from launchctl is alarming
// if you do not know a rename happened.
for legacy in LaunchdService.removeLegacyAgents() {
CLI.note("removed legacy agent \(legacy) (renamed to \(LaunchdService.label))")
}
try LaunchdService.install(executablePath: executablePath, configPath: configPath) try LaunchdService.install(executablePath: executablePath, configPath: configPath)
print("installed \(LaunchdService.agentPlistURL.path)") print("installed \(LaunchdService.agentPlistURL.path)")
print("program: \(RunnerConfig.expandTilde(executablePath)) daemon") print("program: \(RunnerConfig.expandTilde(executablePath)) daemon")
print("logs: \(LaunchdService.logDirectoryURL.path)") print("logs: \(LaunchdService.logDirectoryURL.path)")
print("") print("")
offerLocalNetworkGrant()
print("check it with: gitea-macos-runner service status") print("check it with: gitea-macos-runner service status")
} }
/// Offers to configure Local Network access, if it is not already.
///
/// This is where the question belongs. The agent that was just
/// installed is the process that will be blocked, it has no UI to ask
/// with, and the symptom when it is blocked — every guest boots, no job
/// starts, `No route to host` — points nowhere near the cause. Asking
/// now costs one prompt; not asking costs a debugging session.
///
/// Never fatal: a failed or declined grant leaves a perfectly good
/// installed agent, so this reports and returns rather than throwing.
private func offerLocalNetworkGrant() {
guard grantLocalNetwork != .skip else { return }
guard !LocalNetworkPolicy.status().coversGuestRange else { return }
let method: LocalNetworkPermission.Method
switch grantLocalNetwork {
case .allowlist: method = .allowlist
case .prompt: method = .prompt
case .skip: return // handled above; here for exhaustiveness
case nil:
// Not asked for either way: decide from the terminal. A piped
// or launchd-driven install must not stop on a question, so it
// gets the pointer and carries on.
guard isatty(fileno(stdin)) == 1 else {
CLI.note("""
note: macOS Local Network access is not configured. Until it is, guests \
boot but SSH fails with "No route to host". Configure it with \
`gitea-macos-runner permissions grant`.
""")
print("")
return
}
CLI.note("""
macOS Local Network access is not configured. Without it the agent starts \
guests fine but cannot reach them, and every job fails with "No route to \
host". Granting it writes a subnet allowlist with sudo and needs a reboot.
""")
guard CLI.confirm("configure it now?") else {
CLI.note("skipped; run `gitea-macos-runner permissions grant` later")
print("")
return
}
method = .allowlist
}
// Deliberately swallowed. The agent is installed and correct at
// this point; a declined sudo password should not turn a successful
// install into a failure.
do {
try LocalNetworkGrantFlow.run(method: method)
} catch {
CLI.note("could not configure it: \(error)")
CLI.note("the agent is installed; run `gitea-macos-runner permissions grant` to retry")
}
print("")
}
}
/// `--grant-local-network`'s values: the two grant methods plus an explicit
/// opt-out, which the method enum itself has no business carrying.
///
/// The opt-out case is spelled `skip` rather than `none` so that
/// `choice == .skip` cannot be read as `Optional.none` — the option is
/// itself optional, and "not passed" means something different from
/// "passed `none`".
enum LocalNetworkGrantChoice: String, ExpressibleByArgument, CaseIterable {
case allowlist
case prompt
case skip = "none"
} }
/// `service uninstall` — unload and remove the plist. /// `service uninstall` — unload and remove the plist.
@@ -68,7 +158,11 @@ struct ServiceCommand: AsyncParsableCommand {
func run() async throws { func run() async throws {
let path = LaunchdService.agentPlistURL.path let path = LaunchdService.agentPlistURL.path
let existed = FileManager.default.fileExists(atPath: path) let existed = FileManager.default.fileExists(atPath: path)
let legacy = LaunchdService.removeLegacyAgents()
try LaunchdService.uninstall() try LaunchdService.uninstall()
for label in legacy {
print("removed legacy agent \(label)")
}
print(existed ? "removed \(path)" : "not installed (\(path))") print(existed ? "removed \(path)" : "not installed (\(path))")
} }
} }
+4 -3
View File
@@ -85,9 +85,10 @@ struct VMCommand: AsyncParsableCommand {
} catch { } catch {
CLI.error("\(error)") CLI.error("\(error)")
await session.teardown() await session.teardown()
// Fully qualified: inside a ParsableCommand a bare `exit` // Not `MainActor.run`: the main actor is parked inside
// resolves to ParsableCommand.exit(withError:). // `app.run()` for the life of the process, so hopping onto
await MainActor.run { Foundation.exit(1) } // it to exit is its own deadlock. See `VZAppRuntime.run`.
VZAppRuntime.flushAndExit(1)
} }
} }
) )
+57 -7
View File
@@ -45,6 +45,7 @@ struct GiteaMacOSRunner: AsyncParsableCommand {
ServiceCommand.self, ServiceCommand.self,
DoctorCommand.self, DoctorCommand.self,
ConfigCommand.self, ConfigCommand.self,
PermissionsCommand.self,
], ],
defaultSubcommand: nil defaultSubcommand: nil
) )
@@ -136,15 +137,55 @@ final class OnceFlag: @unchecked Sendable {
final class ProgressPrinter: @unchecked Sendable { final class ProgressPrinter: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var lastLine = "" private var lastLine = ""
private var lastGroup: String?
/// Whether carriage-return rewriting means anything here.
///
/// Piped to a file or captured by `launchd`, `\r` produces one unreadable
/// mega-line, so each update becomes its own line instead. `FileHandle`
/// writes go straight to the descriptor either way — there is no buffer to
/// flush, which is what makes a stall attributable to the stage last
/// printed rather than to output sitting unwritten.
private let isInteractive = isatty(fileno(stderr)) == 1
/// Rewrites the current line. /// Rewrites the current line.
func update(_ line: String) { ///
/// - Parameters:
/// - line: The text to show.
/// - group: Names the stage this line belongs to. When it changes, the
/// outgoing stage's final line is sealed with a newline rather than
/// overwritten — so `installing macOS [####] 100%` is still on screen
/// when the operator scrolls back to work out where the last hour went,
/// instead of being replaced by whatever came next.
func update(_ line: String, group: String? = nil) {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
if let group, let lastGroup, group != lastGroup, !lastLine.isEmpty, isInteractive {
emit("\n")
lastLine = ""
}
if let group { lastGroup = group }
guard line != lastLine else { return } guard line != lastLine else { return }
lastLine = line lastLine = line
let padding = String(repeating: " ", count: max(0, 78 - line.count)) guard isInteractive else {
FileHandle.standardError.write(Data(("\r" + line + padding).utf8)) emit(line + "\n")
return
}
emit("\r" + line + pad(line))
}
/// Emits a standalone line without losing the status line under it.
func line(_ text: String) {
lock.lock()
let carried = lastLine
lock.unlock()
finish(text)
if !carried.isEmpty {
update(carried)
}
} }
/// Ends the line so subsequent output starts cleanly. /// Ends the line so subsequent output starts cleanly.
@@ -152,11 +193,20 @@ final class ProgressPrinter: @unchecked Sendable {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
if let line { if let line {
let padding = String(repeating: " ", count: max(0, 78 - line.count)) emit((isInteractive ? "\r" : "") + line + (isInteractive ? pad(line) : "") + "\n")
FileHandle.standardError.write(Data(("\r" + line + padding + "\n").utf8)) } else if !lastLine.isEmpty, isInteractive {
} else if !lastLine.isEmpty { emit("\n")
FileHandle.standardError.write(Data("\n".utf8))
} }
lastLine = "" lastLine = ""
} }
/// Trailing blanks that erase whatever the previous, longer line left behind.
private func pad(_ line: String) -> String {
String(repeating: " ", count: max(0, 78 - line.count))
}
/// Writes straight to the descriptor. Call with ``lock`` held.
private func emit(_ text: String) {
FileHandle.standardError.write(Data(text.utf8))
}
} }
+1 -1
View File
@@ -64,7 +64,7 @@ struct ConfigTests {
#expect(c.scheduler.pollIntervalSeconds == 5) #expect(c.scheduler.pollIntervalSeconds == 5)
#expect(c.scheduler.reconcileIntervalSeconds == 300) #expect(c.scheduler.reconcileIntervalSeconds == 300)
#expect(c.scheduler.jobTimeoutMinutes == 120) #expect(c.scheduler.jobTimeoutMinutes == 120)
#expect(c.scheduler.bootTimeoutSeconds == 300) #expect(c.scheduler.bootTimeoutSeconds == 900)
#expect(c.guest.username == "admin") #expect(c.guest.username == "admin")
#expect(c.guest.cpuCount == 4) #expect(c.guest.cpuCount == 4)
#expect(c.guest.memoryGB == 8) #expect(c.guest.memoryGB == 8)
@@ -0,0 +1,211 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``LocalNetworkPolicy`` — the arithmetic behind the Local Network
/// subnet allowlist.
///
/// The bug these exist for: an allowlist entry that covers *today's* guest
/// subnet but not tomorrow's. vmnet picks its NAT subnet at runtime and steps
/// to the next free /24 when one is taken, so `192.168.64.0/24` works right up
/// until the day a second VM host appears on the machine — and then every guest
/// connection fails with "No route to host" with the allowlist still looking
/// perfectly configured.
@Suite("LocalNetworkPolicy")
struct LocalNetworkPolicyTests {
// MARK: - Coverage
@Test("an entry must cover the whole vmnet span, not just its first /24")
func coverageIsAllOrNothing() {
// The exact span, and anything wider.
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.64.0/18"))
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.0.0/16"))
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.0.0/8"))
#expect(LocalNetworkPolicy.coversVMNetRange("0.0.0.0/0"))
// The trap: contains 192.168.64.x, but not 192.168.65.x.
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.0/24"))
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.0/19"))
// Adjacent but disjoint.
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.128.0/18"))
#expect(!LocalNetworkPolicy.coversVMNetRange("10.0.0.0/8"))
}
@Test("the RFC 1918 default covers the guest range")
func defaultSubnetsCoverGuests() {
#expect(LocalNetworkPolicy.defaultSubnets.contains(where: LocalNetworkPolicy.coversVMNetRange))
}
@Test("a prefix is required for coverage; a bare address is a /32")
func bareAddressIsASingleHost() {
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.1"))
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.1/32"))
}
@Test("host bits below the prefix do not change the block")
func hostBitsAreMaskedOff() {
// 192.168.70.5/18 and 192.168.64.0/18 are the same block.
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.70.5/18"))
}
// MARK: - Rejecting what macOS would silently ignore
@Test("malformed, IPv6 and hostname entries are rejected, not crashed on")
func garbageIsRejected() {
for entry in [
"", "/", "/24", "192.168.64.0/", "192.168.64.0/33", "192.168.64.0/-1",
"192.168.64", "192.168.64.0.1", "192.168.256.0/18", "192.168.64.x/18",
"fd00::/8", "::/0", "localhost", "example.com/24", "192.168.64.0/18/24",
] {
#expect(!LocalNetworkPolicy.isValidSubnet(entry), "\(entry) should be rejected")
#expect(!LocalNetworkPolicy.coversVMNetRange(entry), "\(entry) should not cover")
}
}
@Test("well-formed entries validate")
func goodEntriesValidate() {
for entry in ["0.0.0.0/0", "10.0.0.0/8", "192.168.64.0/24", "192.168.64.1", "255.255.255.255/32"] {
#expect(LocalNetworkPolicy.isValidSubnet(entry), "\(entry) should validate")
}
}
@Test("ipv4Value packs octets most-significant first")
func addressPacking() {
#expect(LocalNetworkPolicy.ipv4Value("0.0.0.0") == 0)
#expect(LocalNetworkPolicy.ipv4Value("192.168.64.0") == 0xC0A8_4000)
#expect(LocalNetworkPolicy.ipv4Value("192.168.127.255") == 0xC0A8_7FFF)
#expect(LocalNetworkPolicy.ipv4Value("255.255.255.255") == 0xFFFF_FFFF)
#expect(LocalNetworkPolicy.ipv4Value("192.168.64") == nil)
#expect(LocalNetworkPolicy.ipv4Value("192.168.64.256") == nil)
}
// MARK: - What gets written
@Test("both interface keys are written, each with the subnets as separate arguments")
func writeArgumentsCoverBothKeys() {
let subnets = ["10.0.0.0/8", "192.168.0.0/16"]
let commands = LocalNetworkPolicy.writeArguments(subnets: subnets)
#expect(commands.count == 2)
#expect(commands[0] == ["write", LocalNetworkPolicy.domain, LocalNetworkPolicy.ethernetKey,
"-array", "10.0.0.0/8", "192.168.0.0/16"])
#expect(commands[1] == ["write", LocalNetworkPolicy.domain, LocalNetworkPolicy.wifiKey,
"-array", "10.0.0.0/8", "192.168.0.0/16"])
// Each subnet is its own argv element. Joined into one string, macOS
// would read the whole thing as a single unparseable entry and grant
// nothing — while `defaults read` still showed something plausible.
for command in commands {
#expect(!command.contains { $0.contains(" ") })
}
}
@Test("the shell rendering quotes anything a shell would reinterpret")
func shellRenderingIsSafe() {
let lines = LocalNetworkPolicy.writeCommandLines(subnets: ["10.0.0.0/8", "a b; rm -rf /"])
#expect(lines.count == 2)
for line in lines {
#expect(line.hasPrefix("sudo defaults write \(LocalNetworkPolicy.domain) "))
// Plain CIDR stays readable; the hostile entry gets quoted.
#expect(line.contains(" 10.0.0.0/8 "))
#expect(line.contains("'a b; rm -rf /'"))
}
}
// MARK: - Reading the host back
@Test("status reads the live host without throwing and stays self-consistent")
func statusIsSelfConsistent() {
// Cannot assert the host's actual configuration — this suite runs on
// developer machines and in CI guests alike. What must hold either way
// is that the derived flags agree with the entries.
let status = LocalNetworkPolicy.status()
#expect(status.isConfigured == !status.allowlist.isEmpty)
#expect(status.coversGuestRange == status.allowlist.contains(where: LocalNetworkPolicy.coversVMNetRange))
if status.allowlist.isEmpty { #expect(status.sourcePaths.isEmpty) }
#expect(Set(status.allowlist).count == status.allowlist.count, "entries should be deduplicated")
}
@Test("all three candidate preference paths are checked")
func candidatePathsCoverBothSudoOutcomes() {
let candidates = LocalNetworkPolicy.preferenceCandidates()
// `sudo defaults write` lands in root's preferences or the invoking
// user's depending on whether sudo preserved HOME, so both must be
// checked — plus the system-wide location.
#expect(candidates.contains("/var/root/Library/Preferences/\(LocalNetworkPolicy.domain).plist"))
#expect(candidates.contains("/Library/Preferences/\(LocalNetworkPolicy.domain).plist"))
#expect(candidates.contains(NSHomeDirectory() + "/Library/Preferences/\(LocalNetworkPolicy.domain).plist"))
}
// MARK: - Parsing preferences
/// A preferences file carrying `entries` under both allowlist keys.
private func preferences(_ entries: [String]) throws -> Data {
try PropertyListSerialization.data(
fromPropertyList: [
LocalNetworkPolicy.ethernetKey: entries,
LocalNetworkPolicy.wifiKey: entries,
"UnrelatedKey": "ignored",
],
format: .xml,
options: 0)
}
@Test("both keys are read, and the same entry in both is not counted twice")
func entriesAreUnionedAcrossKeys() throws {
let data = try preferences(["10.0.0.0/8", "192.168.0.0/16"])
#expect(LocalNetworkPolicy.entries(inPreferences: data) == ["10.0.0.0/8", "192.168.0.0/16"])
}
@Test("data that is not a preferences file reads as empty rather than throwing")
func unparseablePreferencesReadEmpty() {
#expect(LocalNetworkPolicy.entries(inPreferences: Data("not a plist".utf8)).isEmpty)
#expect(LocalNetworkPolicy.entries(inPreferences: Data()).isEmpty)
}
@Test("only files that contribute an entry are named as sources")
func sourcePathsNameOnlyContributingFiles() throws {
let empty = try preferences([])
let real = try preferences(["192.168.0.0/16"])
// The same entries again: a second copy of a value already seen adds
// nothing, so its path must not be reported as a source.
let duplicate = try preferences(["192.168.0.0/16"])
let status = LocalNetworkPolicy.status(fromContentsOf: [
("/first.plist", empty),
("/second.plist", real),
("/third.plist", duplicate),
])
#expect(status.allowlist == ["192.168.0.0/16"])
#expect(status.sourcePaths == ["/second.plist"])
#expect(status.coversGuestRange)
}
@Test("an unreadable candidate makes the answer unknown, not unconfigured")
func unreadableCandidatesAreIndeterminate() throws {
// The real case: `sudo defaults write` lands in /var/root, which is
// mode 700, so an ordinary user is refused before it can learn whether
// the file is even there. Reporting that as "no allowlist" is how a
// successful grant gets called a failure.
let blind = LocalNetworkPolicy.status(
fromContentsOf: [], unreadablePaths: ["/var/root/Library/Preferences/x.plist"])
#expect(!blind.isConfigured)
#expect(blind.isIndeterminate)
// Nothing found and nothing refused really is unconfigured.
let empty = LocalNetworkPolicy.status(fromContentsOf: [])
#expect(!empty.isConfigured)
#expect(!empty.isIndeterminate)
// Something found outweighs a refusal elsewhere: the answer is known.
let found = LocalNetworkPolicy.status(
fromContentsOf: [("/a.plist", try preferences(["10.0.0.0/8"]))],
unreadablePaths: ["/var/root/Library/Preferences/x.plist"])
#expect(found.isConfigured)
#expect(!found.isIndeterminate)
}
}
@@ -0,0 +1,254 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``PathResolution`` — the CLI's defence against shell quoting.
///
/// The bug these exist for: `--ipsw ~/Downloads/UniversalMac_27.0_*.ipsw`
/// behaves differently depending on whether the shell expanded the glob, so the
/// tool has to resolve the argument itself and stop caring.
@Suite("PathResolution")
struct PathResolutionTests {
/// A scratch directory holding `names`, deleted when `body` returns.
private func withFiles(_ names: [String], _ body: (String) throws -> Void) throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("gmr-path-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
for name in names {
let path = dir.appendingPathComponent(name)
#expect(FileManager.default.createFile(atPath: path.path, contents: Data()))
}
try body(dir.path)
}
// MARK: - Pattern detection
@Test("only glob metacharacters make a string a pattern")
func patternDetection() {
#expect(!PathResolution.isPattern("/tmp/UniversalMac_27.0.ipsw"))
// A tilde is expanded either way; it does not make this a glob.
#expect(!PathResolution.isPattern("~/Downloads/x.ipsw"))
#expect(PathResolution.isPattern("/tmp/a_*.ipsw"))
#expect(PathResolution.isPattern("/tmp/a_?.ipsw"))
#expect(PathResolution.isPattern("/tmp/a_[12].ipsw"))
}
// MARK: - Passthrough
@Test("a plain path is returned untouched")
func plainPathPassesThrough() throws {
// Deliberately not checked for existence: the caller's own error knows
// what the file was for and says something more useful than we could.
#expect(try PathResolution.resolve("/tmp/nope.ipsw", label: "--ipsw") == "/tmp/nope.ipsw")
#expect(try PathResolution.resolve("relative/x.ipsw", label: "--ipsw") == "relative/x.ipsw")
}
@Test("a leading tilde is expanded")
func tildeIsExpanded() throws {
let home = NSHomeDirectory()
#expect(try PathResolution.resolve("~/Downloads/x.ipsw", label: "--ipsw") == home + "/Downloads/x.ipsw")
// Only leading: a tilde inside a path component is an ordinary character.
#expect(try PathResolution.resolve("/tmp/~x.ipsw", label: "--ipsw") == "/tmp/~x.ipsw")
}
// MARK: - Globbing
@Test("a pattern matching exactly one file resolves to it")
func singleMatchResolves() throws {
try withFiles(["UniversalMac_27.0_ABC.ipsw", "notes.txt"]) { dir in
let resolved = try PathResolution.resolve("\(dir)/UniversalMac_27.0_*.ipsw", label: "--ipsw")
#expect(resolved == "\(dir)/UniversalMac_27.0_ABC.ipsw")
}
}
@Test("a pattern matching nothing is a clear notFound")
func zeroMatchesThrows() throws {
try withFiles(["a_1.ipsw"]) { dir in
#expect(throws: CoreError.self) {
try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
}
do {
_ = try PathResolution.resolve("\(dir)/nope_*.ipsw", label: "--ipsw")
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .notFound(let message) = error else {
Issue.record("expected .notFound, got \(error)")
return
}
#expect(message.contains("--ipsw"))
#expect(message.contains("no file matches"))
#expect(message.contains("\(dir)/nope_*.ipsw"))
}
}
}
@Test("a pattern matching several files lists them, sorted, and refuses")
func multipleMatchesThrowsAndLists() throws {
// Created out of order: the message must not depend on creation order,
// because the operator is being asked to read it and pick one.
try withFiles(["a_2.ipsw", "a_10.ipsw", "a_1.ipsw"]) { dir in
do {
_ = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .configInvalid(let message) = error else {
Issue.record("expected .configInvalid, got \(error)")
return
}
#expect(message.contains("--ipsw"))
#expect(message.contains("3 files match"))
for name in ["a_1.ipsw", "a_2.ipsw", "a_10.ipsw"] {
#expect(message.contains("\(dir)/\(name)"))
}
// Sorted, so two runs read identically.
let one = message.range(of: "a_1.ipsw")!.lowerBound
let ten = message.range(of: "a_10.ipsw")!.lowerBound
let two = message.range(of: "a_2.ipsw")!.lowerBound
#expect(one < ten)
#expect(ten < two)
#expect(message.contains("name exactly one of them"))
}
}
}
@Test("brackets are a character class when they match, and a filename when they do not")
func bracketsAreGlobOnlyWhenTheyAreOne() throws {
// Used as a class: `[12]` selects the one file that exists.
try withFiles(["build_1.ipsw"]) { dir in
let asClass = try PathResolution.resolve("\(dir)/build_[12].ipsw", label: "--ipsw")
#expect(asClass == "\(dir)/build_1.ipsw")
}
// A real file whose name contains brackets. Read as a class it matches
// nothing, so the literal has to win — otherwise a legal filename is
// unreachable through this flag.
try withFiles(["report[1].ipsw"]) { dir in
let asLiteral = try PathResolution.resolve("\(dir)/report[1].ipsw", label: "--ipsw")
#expect(asLiteral == "\(dir)/report[1].ipsw")
}
}
@Test("a pattern that resolves is not confused by neighbours of another extension")
func matchingIsScopedToThePattern() throws {
try withFiles(["a_1.ipsw", "a_1.ipsw.part", "a_1.txt"]) { dir in
let resolved = try PathResolution.resolve("\(dir)/a_*.ipsw", label: "--ipsw")
#expect(resolved == "\(dir)/a_1.ipsw")
}
}
// MARK: - IPSW pre-validation
/// Writes `bytes` at the front of a sparse file of `size` bytes.
///
/// Sparse because a valid-size fixture is a gigabyte and nobody should wait
/// for a gigabyte of zeroes to be written to test a two-byte check.
private func withIPSW(bytes: [UInt8], size: UInt64, _ body: (String) throws -> Void) throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("gmr-ipsw-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let path = dir.appendingPathComponent("image.ipsw").path
#expect(FileManager.default.createFile(atPath: path, contents: Data(bytes)))
let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: path))
try handle.truncate(atOffset: size)
try handle.close()
try body(path)
}
private let pk: [UInt8] = [0x50, 0x4B]
private let validSize: UInt64 = 2_000_000_000
@Test("a plausible restore image passes")
func healthyIPSWValidates() throws {
try withIPSW(bytes: pk, size: validSize) { path in
try IPSWFile.validate(path: path)
}
}
@Test("a missing file is notFound, not a hang")
func missingIPSWThrows() throws {
#expect(throws: CoreError.self) {
try IPSWFile.validate(path: "/tmp/gmr-definitely-absent.ipsw")
}
}
@Test("a directory is rejected before the framework sees it")
func directoryIsRejected() throws {
try withFiles([]) { dir in
do {
try IPSWFile.validate(path: dir)
Issue.record("expected a throw")
} catch let error as CoreError {
#expect("\(error)".contains("directory"))
}
}
}
@Test("a truncated download names its own size and says what happened")
func truncatedIPSWIsRejected() throws {
// 4 MB: the shape of a download that stopped early — right magic bytes,
// nowhere near the right length.
try withIPSW(bytes: pk, size: 4_000_000) { path in
do {
try IPSWFile.validate(path: path)
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .configInvalid(let message) = error else {
Issue.record("expected .configInvalid, got \(error)")
return
}
#expect(message.contains("3.8 MB"))
#expect(message.contains("incomplete"))
}
}
}
@Test("an empty file is rejected on size, before anything reads it")
func emptyIPSWIsRejected() throws {
try withIPSW(bytes: [], size: 0) { path in
#expect(throws: CoreError.self) { try IPSWFile.validate(path: path) }
}
}
@Test("a big file that is not a zip is rejected on its magic bytes")
func wrongMagicIsRejected() throws {
try withIPSW(bytes: [0x00, 0x01], size: validSize) { path in
do {
try IPSWFile.validate(path: path)
Issue.record("expected a throw")
} catch let error as CoreError {
guard case .configInvalid(let message) = error else {
Issue.record("expected .configInvalid, got \(error)")
return
}
#expect(message.contains("PK"))
#expect(message.contains("0001"))
}
}
}
@Test("sizes are rendered the way the operator will compare them")
func sizeDescriptions() {
#expect(IPSWFile.describeSize(0) == "0 B")
#expect(IPSWFile.describeSize(512) == "512 B")
#expect(IPSWFile.describeSize(22_567_352_533) == "21.0 GB")
}
@Test("the label names the offending flag so the operator knows what to fix")
func labelAppearsInErrors() throws {
try withFiles([]) { dir in
do {
_ = try PathResolution.resolve("\(dir)/*.xip", label: "--xcode-xip")
Issue.record("expected a throw")
} catch let error as CoreError {
#expect("\(error)".contains("--xcode-xip"))
}
}
}
}
@@ -0,0 +1,116 @@
import Foundation
import Testing
@testable import RunnerCore
/// Tests for ``XcodeInstall`` — the decidable parts of installing Xcode.
///
/// The bug these exist for: the installer assumed the archive expanded to
/// `Xcode.app`. A beta expands to `Xcode-beta.app`, so a 12 GB upload and a
/// half-hour expansion both succeeded and then the final `mv` failed with
/// "No such file or directory". Nothing about that is worth discovering from a
/// forty-minute live run twice.
@Suite("XcodeInstall")
struct XcodeInstallTests {
// MARK: - Discovering the expanded app
@Test("a release archive's Xcode.app is found")
func findsReleaseApp() throws {
let path = try XcodeInstall.expandedAppPath(
fromListing: "/tmp/xcode-expand/Xcode.app\n", staging: "/tmp/xcode-expand")
#expect(path == "/tmp/xcode-expand/Xcode.app")
}
/// The reported failure, in one line.
@Test("a beta archive's Xcode-beta.app is found")
func findsBetaApp() throws {
let path = try XcodeInstall.expandedAppPath(
fromListing: "/tmp/xcode-expand/Xcode-beta.app", staging: "/tmp/xcode-expand")
#expect(path == "/tmp/xcode-expand/Xcode-beta.app")
}
@Test("a version-qualified name is found")
func findsVersionQualifiedApp() throws {
let path = try XcodeInstall.expandedAppPath(
fromListing: " /tmp/xcode-expand/Xcode_16.2.app \n\n", staging: "/tmp/xcode-expand")
#expect(path == "/tmp/xcode-expand/Xcode_16.2.app")
}
/// `/bin/sh` echoes an unmatched glob back verbatim, so "no match" arrives
/// as a plausible-looking path rather than as empty output.
@Test("an unmatched glob reads as no match, not as a path")
func unmatchedGlobIsNotAPath() {
#expect(throws: CoreError.self) {
try XcodeInstall.expandedAppPath(
fromListing: "/tmp/xcode-expand/*.app\n", staging: "/tmp/xcode-expand")
}
}
@Test("empty output is an error naming the staging directory")
func emptyListingThrows() {
do {
_ = try XcodeInstall.expandedAppPath(fromListing: "\n \n", staging: "/tmp/xcode-expand")
Issue.record("expected a failure")
} catch {
#expect("\(error)".contains("/tmp/xcode-expand"))
}
}
/// Guessing between two candidates would install something the operator did
/// not ask for, so this stops instead.
@Test("several apps is an error listing them")
func ambiguousListingThrows() {
do {
_ = try XcodeInstall.expandedAppPath(
fromListing: "/tmp/x/Xcode.app\n/tmp/x/Xcode-beta.app\n", staging: "/tmp/x")
Issue.record("expected a failure")
} catch {
let text = "\(error)"
#expect(text.contains("Xcode.app"))
#expect(text.contains("Xcode-beta.app"))
}
}
// MARK: - Disk arithmetic
@Test("the requirement covers the archive and its expansion")
func requiredFreeSpaceCoversBoth() {
let xip = 12_000_000_000
#expect(XcodeInstall.expansionEstimateBytes(xipBytes: xip) == 42_000_000_000)
#expect(XcodeInstall.requiredFreeBytes(xipBytes: xip) == 54_000_000_000)
}
@Test("df -Pk output yields available bytes")
func parsesDF() {
let output = """
Filesystem 1024-blocks Used Available Capacity Mounted on
/dev/disk3s5 488245288 120000000 62914560 66% /
"""
#expect(XcodeInstall.availableBytes(dfOutput: output) == 62_914_560 * 1024)
}
/// Unparseable output means "could not check", not "no space" — the caller
/// must not refuse to install because `df` printed something unexpected.
@Test("unparseable df output yields nil rather than zero")
func unparseableDFIsNil() {
#expect(XcodeInstall.availableBytes(dfOutput: "df: /nope: No such file or directory") == nil)
#expect(XcodeInstall.availableBytes(dfOutput: "") == nil)
}
@Test("the shortfall message names every number and a remedy")
func messageNamesTheNumbers() {
let text = XcodeInstall.insufficientDiskMessage(
xipBytes: 12_000_000_000, availableBytes: 20_000_000_000)
#expect(text.contains("20.0 GB")) // available
#expect(text.contains("54.0 GB")) // needed
#expect(text.contains("12.0 GB")) // the archive
#expect(text.contains("--disk-gb"))
}
@Test("byte counts render as decimal gigabytes")
func formatsGigabytes() {
#expect(XcodeInstall.formatGB(12_400_000_000) == "12.4 GB")
#expect(XcodeInstall.formatGB(0) == "0.0 GB")
}
}
+85 -9
View File
@@ -21,8 +21,9 @@ Three constraints shape everything below:
`VZError.virtualMachineLimitExceeded` from `start()`. Concurrency is therefore `VZError.virtualMachineLimitExceeded` from `start()`. Concurrency is therefore
2, permanently, and the config value is clamped rather than trusted. 2, permanently, and the config value is clamped rather than trusted.
2. **Virtualization needs a GUI session and a signed bundle.** The daemon runs as 2. **Virtualization needs a GUI session and a signed bundle.** The daemon runs as
a LaunchAgent in a logged-in user session, from inside an ad-hoc-signed `.app` a LaunchAgent in a logged-in user session, from inside a signed `.app` carrying
carrying `com.apple.security.virtualization`. `com.apple.security.virtualization` — Developer ID when a certificate is
available, ad-hoc otherwise (see "Verified facts", item 10).
3. **Gitea decides which job a runner claims, not us.** We supply capacity; the 3. **Gitea decides which job a runner claims, not us.** We supply capacity; the
server matches. Trying to pin a specific job to a specific VM would mean server matches. Trying to pin a specific job to a specific VM would mean
reimplementing Gitea's matching rules, and would be wrong the moment they reimplementing Gitea's matching rules, and would be wrong the moment they
@@ -45,7 +46,7 @@ Three constraints shape everything below:
┌──────────────────────────────── Host (Apple Silicon Mac, macOS 26+) ─────────────────────────────┐ ┌──────────────────────────────── Host (Apple Silicon Mac, macOS 26+) ─────────────────────────────┐
│ │ │ │
│ LaunchAgent (user session, auto-login, login.keychain unlocked) │ │ LaunchAgent (user session, auto-login, login.keychain unlocked) │
│ └── GiteaMacosRunner.app (ad-hoc signed, com.apple.security.virtualization, LSUIElement) │ │ └── GiteaMacosRunner.app (signed, com.apple.security.virtualization, LSUIElement) │
│ │ │ │ │ │
│ │ NSApplication(.prohibited).run() ── main thread, required by Virtualization │ │ │ NSApplication(.prohibited).run() ── main thread, required by Virtualization │
│ │ │ │ │ │
@@ -284,8 +285,18 @@ scheduler treats as transient back-pressure rather than a failure.
### Timeouts ### Timeouts
* A slot in `.provisioning(since:)` longer than `bootTimeoutSeconds` (default * A slot in `.provisioning(since:)` longer than `bootTimeoutSeconds` (default
300) is torn down. Covers a guest that never gets a lease, never starts `sshd`, 900) is torn down. Covers a guest that never gets a lease, never starts `sshd`,
or hangs in Setup Assistant. or hangs in Setup Assistant. The default is deliberately generous: several
Virtualization guests sharing one host push a boot from tens of seconds into
minutes, and a limit below the worst case does not time out a bad boot, it
livelocks — each replacement clone starts from zero and adds load, so the next
boot is slower still and no runner ever registers.
* The lifecycle's own `waitForLease` and `waitForSSH` budgets are derived from
what is *left* of that deadline, not from a fresh copy of it. Given the full
`bootTimeoutSeconds` their deadlines would fall after the planner's, so the
planner would always cancel first and the specific error — which host, how
many attempts, what the last one said — would be discarded in favour of a bare
cancellation.
* A slot in `.running(jobHint:since:)` longer than `jobTimeoutMinutes` (default * A slot in `.running(jobHint:since:)` longer than `jobTimeoutMinutes` (default
120) is torn down. Covers a job that hangs. This is comfortably below Gitea's 120) is torn down. Covers a job that hangs. This is comfortably below Gitea's
own `ABANDONED_JOB_TIMEOUT` (24 h), so our teardown always happens first and own `ABANDONED_JOB_TIMEOUT` (24 h), so our teardown always happens first and
@@ -372,7 +383,8 @@ disposable and isolated, not on the job being constrained inside it.
and nothing else. and nothing else.
* Networking is **NAT**, not bridged. Guests can reach the LAN and Gitea, but are * Networking is **NAT**, not bridged. Guests can reach the LAN and Gitea, but are
not first-class hosts on it. Bridged networking would require the restricted not first-class hosts on it. Bridged networking would require the restricted
`com.apple.vm.networking` entitlement, which ad-hoc signing cannot grant — a `com.apple.vm.networking` entitlement, which needs an Apple-approved
provisioning profile and which ad-hoc signing cannot grant at all — a
constraint that happens to align with what we want anyway. constraint that happens to align with what we want anyway.
* **SSH host keys are not verified.** The peer is a VM this process booted * **SSH host keys are not verified.** The peer is a VM this process booted
moments ago on a link no other machine shares; pinning would break on every moments ago on a link no other machine shares; pinning would break on every
@@ -517,9 +529,10 @@ timeout. The `--manual-setup` fallback is out of v1 scope (§4).
**10. Headless Virtualization requires an `NSApplication` run loop with **10. Headless Virtualization requires an `NSApplication` run loop with
`.prohibited` activation policy, inside a signed `.app` bundle** carrying `.prohibited` activation policy, inside a signed `.app` bundle** carrying
`com.apple.security.virtualization`. Ad-hoc signing (`codesign -s -`) suffices. `com.apple.security.virtualization`. That entitlement is unrestricted: ad-hoc
Bridged networking would additionally need a restricted entitlement; NAT does signing (`codesign -s -`) grants it, and a Developer ID certificate grants it
not. with no provisioning profile. Bridged networking would additionally need a
restricted entitlement; NAT does not.
→ *Consequence:* `CommandDaemon` starts `NSApplication` and runs the orchestrator → *Consequence:* `CommandDaemon` starts `NSApplication` and runs the orchestrator
in a detached `Task`. This applies to **every** command that starts a VM, not in a detached `Task`. This applies to **every** command that starts a VM, not
just the daemon: `vm boot`, `image build`, and `image provision` all go through just the daemon: `vm boot`, `image build`, and `image provision` all go through
@@ -540,6 +553,24 @@ downgrade that only surfaces as a failed VM start. And `bundle` must copy
`.app` without them is a working binary with a broken `image build`, `.app` without them is a working binary with a broken `image build`,
`service install`, and `config init`. `service install`, and `config init`.
**10a. Which signature is used decides whether the app's code identity is stable
across rebuilds.** A Developer ID signature's designated requirement is anchored
to the team (`… and certificate leaf[subject.OU] = <TEAM_ID>`), so every build
is the same program to macOS. An ad-hoc signature has no anchor, so identity
falls back to the main executable's Mach-O UUID, which the linker regenerates on
essentially every link.
→ *Consequence:* this is not cosmetic, because macOS Local Network privacy is
keyed on exactly that UUID (Fact 16). Under ad-hoc signing a grant is
withdrawn by the next `make install`; under Developer ID it persists. `make sign`
therefore selects a `Developer ID Application` identity matching `TEAM_ID` when
the keychain has one and falls back to ad-hoc with a warning when it does not —
the fallback is required because CI builds inside a throwaway guest with neither
keychain nor certificate. The Developer ID path also passes `--options runtime
--timestamp`, so the bundle is notarizable later without re-signing; notarization
itself is skipped, since it governs distribution to other Macs and this app is
built and installed in place. `Doctor.checkCodeSignature` reports which path was
taken and warns on ad-hoc.
**11. macOS 15+ requires an unlocked `login.keychain` to start a VM.** **11. macOS 15+ requires an unlocked `login.keychain` to start a VM.**
→ *Consequence:* the service **must** be a LaunchAgent in the auto-logged-in → *Consequence:* the service **must** be a LaunchAgent in the auto-logged-in
user's session, never a LaunchDaemon (which has no session and no unlocked user's session, never a LaunchDaemon (which has no session and no unlocked
@@ -579,3 +610,48 @@ changing the MAC address or ECID.**
prohibition collides directly with the per-slot MAC scheme from Fact 12, so prohibition collides directly with the per-slot MAC scheme from Fact 12, so
adopting it would require per-slot saved states and a careful look at DHCP lease adopting it would require per-slot saved states and a careful look at DHCP lease
reuse — not a drop-in optimization. reuse — not a drop-in optimization.
**16. macOS 15+ Local Network privacy can block host→guest connections, and
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
executable UUID as part of its implementation". A denial returns `EHOSTUNREACH`
(errno 65), indistinguishable from a genuinely unreachable host. Three things
then conspire against the interactive grant: a LaunchAgent has no UI to show the
prompt in; a run started from a shell is attributed to the **responsible
process**, so the prompt and the System Settings row belong to Terminal rather
than to this app, and granting it to Terminal does not carry to the agent; and
under ad-hoc signing the UUID keying (Fact 10a) withdraws the grant on the next
rebuild.
→ *Consequence:* the deterministic fix is the subnet allowlist
(`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.
`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; `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.
+139 -34
View File
@@ -162,8 +162,9 @@ SSH server, so the build appears to hang. See
Virtualization.framework refuses to run unless the calling binary carries the Virtualization.framework refuses to run unless the calling binary carries the
`com.apple.security.virtualization` entitlement, and entitlements are only honoured on a signed `com.apple.security.virtualization` entitlement, and entitlements are only honoured on a signed
binary inside a proper `.app` bundle. Ad-hoc signing (`codesign -s -`) satisfies this — **no paid binary inside a proper `.app` bundle. That entitlement is not restricted, so ad-hoc signing
Apple developer account is needed.** (`codesign -s -`) satisfies it — **the runner works with no Apple developer account.** A Developer
ID certificate buys something different and worth having; see [Code signing](#code-signing) below.
```sh ```sh
git clone <this repo> && cd gitea-macos-runner git clone <this repo> && cd gitea-macos-runner
@@ -176,7 +177,7 @@ make install
| --- | --- | | --- | --- |
| `make build` | `swift build -c release --arch arm64` | | `make build` | `swift build -c release --arch arm64` |
| `make bundle` | Assemble `GiteaMacosRunner.app` around the binary: `Contents/MacOS/gitea-macos-runner`, `Contents/Info.plist`, and `Contents/Resources/` (`provision.sh`, `launchd.plist.template`, `config.example.json`) | | `make bundle` | Assemble `GiteaMacosRunner.app` around the binary: `Contents/MacOS/gitea-macos-runner`, `Contents/Info.plist`, and `Contents/Resources/` (`provision.sh`, `launchd.plist.template`, `config.example.json`) |
| `make sign` | `codesign --sign - --entitlements …` (ad-hoc) and print the resulting entitlements | | `make sign` | Sign the bundle with the virtualization entitlement — Developer ID when a matching certificate is in the keychain, ad-hoc otherwise — then print the entitlements and the resulting identity |
| `make all` | `build` + `bundle` + `sign`. The default target. | | `make all` | `build` + `bundle` + `sign`. The default target. |
| `make install` | Runs `all`, copies the app to `~/Applications`, and symlinks the CLI to `/usr/local/bin/gitea-macos-runner` | | `make install` | Runs `all`, copies the app to `~/Applications`, and symlinks the CLI to `/usr/local/bin/gitea-macos-runner` |
| `make dev` | Debug build + bundle + sign, for fast iteration. Does **not** install. | | `make dev` | Debug build + bundle + sign, for fast iteration. Does **not** install. |
@@ -192,6 +193,58 @@ code looks in `Contents/Resources` first and only then falls back to
repo-relative paths, so an installed `.app` missing them is a working binary repo-relative paths, so an installed `.app` missing them is a working binary
with three broken commands. with three broken commands.
### Code signing
`make sign` picks its identity automatically:
| Keychain state | What you get |
| --- | --- |
| A `Developer ID Application` certificate whose team matches `TEAM_ID` | Developer ID signature, hardened runtime (`--options runtime`), trusted timestamp (`--timestamp`) |
| No matching certificate | Ad-hoc signature (`codesign --sign -`), with a warning |
Both produce a bundle that boots VMs — the virtualization entitlement is not restricted, and needs
no provisioning profile on either path. What differs is **code identity stability**, and that is the
whole reason to prefer Developer ID:
- A Developer ID signature carries a designated requirement anchored to your team
(`… and certificate leaf[subject.OU] = L7UDTQ6F5W`). Every subsequent build satisfies it, so macOS
recognises rebuild after rebuild as *the same program*.
- An ad-hoc signature has no anchor, so the system falls back to the main executable's Mach-O UUID —
which the linker regenerates on essentially every link. Each `make install` presents a program
macOS has never seen before.
The practical consequence is Local Network privacy (§2.6): per
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
the grant "uses your main executable UUID as part of its implementation", so under ad-hoc signing it
is silently withdrawn by the next rebuild. Under Developer ID it survives.
The team is baked into the `Makefile` as a default; override it for your own certificate:
```sh
make install TEAM_ID=ABCDE12345 # your Developer ID team
make install TEAM_ID= # force ad-hoc even if a certificate exists
```
Confirm what actually landed — `doctor`'s `code identity` check reports it, or ask `codesign`:
```sh
codesign -dvv ~/Applications/GiteaMacosRunner.app
# Identifier=xyz.blakeslee.gitea-macos-vm-orchestrator
# CodeDirectory v=20500 … flags=0x10000(runtime)
# Authority=Developer ID Application: Your Name (ABCDE12345)
# TeamIdentifier=ABCDE12345
```
`TeamIdentifier=not set` and `flags=0x2(adhoc)` mean the ad-hoc path was taken.
Two things this deliberately does **not** do. The bundle is not **notarized**: notarization matters
for software distributed to other Macs, where Gatekeeper checks the quarantine bit, and this app is
built and installed in place. `spctl -a` therefore reports `rejected: Unnotarized Developer ID`,
which is expected and does not stop anything here. Signing does require network access for
`--timestamp`, so an offline host falls back to ad-hoc. And the entitlements list stays minimal:
`com.apple.vm.networking` — needed only for bridged networking, and genuinely restricted — is not
requested. See [DESIGN.md](DESIGN.md).
If `/usr/local/bin` isn't writable, `make install` says so and prints the `sudo ln -sf …` command to If `/usr/local/bin` isn't writable, `make install` says so and prints the `sudo ln -sf …` command to
run yourself. run yourself.
@@ -277,7 +330,7 @@ is required; the file form wins over the inline form when both are present.
| `pollIntervalSeconds` | `5` | How often to poll the queued-jobs API. | | `pollIntervalSeconds` | `5` | How often to poll the queued-jobs API. |
| `reconcileIntervalSeconds` | `300` | How often to sweep Gitea for orphaned runner registrations from uncleanly-killed VMs. | | `reconcileIntervalSeconds` | `300` | How often to sweep Gitea for orphaned runner registrations from uncleanly-killed VMs. |
| `jobTimeoutMinutes` | `120` | Wall-clock limit for one job; the VM is destroyed when exceeded. | | `jobTimeoutMinutes` | `120` | Wall-clock limit for one job; the VM is destroyed when exceeded. |
| `bootTimeoutSeconds` | `300` | Time allowed from VM start to a usable SSH connection. | | `bootTimeoutSeconds` | `900` | Time allowed from VM start to a usable SSH connection. |
**`guest`** **`guest`**
@@ -315,6 +368,17 @@ find it. `--ipsw` is optional: omit it and the latest supported restore image is
downloaded into `storeDir/ipsw/` first, which is most of the build's wall-clock downloaded into `storeDir/ipsw/` first, which is most of the build's wall-clock
time. `--disk-gb` overrides `guest.diskGB` for this image only. time. `--disk-gb` overrides `guest.diskGB` for this image only.
`--ipsw` (and `image provision --xcode-xip`) accept a `~` and a glob, quoted or not — these are
equivalent, and neither depends on what your shell did with the pattern first:
```sh
gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_*.ipsw
gitea-macos-runner image build --ipsw '~/Downloads/UniversalMac_27.0_*.ipsw'
```
A pattern must identify exactly one file. If it matches several, the build stops before doing any
work and lists them so you can name the one you meant; if it matches none, it says so.
This takes a long time — macOS installs from the IPSW, boots, and is then provisioned over SSH. This takes a long time — macOS installs from the IPSW, boots, and is then provisioned over SSH.
**Do not interrupt it during the install phase.** Stopping a VM mid-install leaves the disk image **Do not interrupt it during the install phase.** Stopping a VM mid-install leaves the disk image
in an undefined state; delete the image and start over rather than trying to resume. in an undefined state; delete the image and start over rather than trying to resume.
@@ -377,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 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. 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. `service uninstall` removes the LaunchAgent; it does not delete images or config.
### 2.6 macOS 15+ Local Network privacy prompt ### 2.6 macOS 15+ Local Network privacy prompt
@@ -385,43 +454,77 @@ 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.
**On a CI host, use the subnet allowlist.** It is the only deterministic option — no prompt, no GUI `service install` offers to configure this, and it can also be done at any time:
session, and nothing to redo after a rebuild:
```sh ```sh
sudo defaults write com.apple.network.local-network \ gitea-macos-runner permissions status # what is configured, and what to do about it
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24" gitea-macos-runner permissions grant # configure it
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
``` ```
Then **reboot** — these are read at boot, so restarting the service alone is not enough. Adjust the One asymmetry to know about before reading any of this output: the allowlist is written as root and
range to match the subnet Virtualization.framework's NAT hands out on your host (check lands in `/var/root/Library/Preferences/`, which is mode 700. An ordinary login cannot read it back —
`/var/db/dhcpd_leases` after a VM boots); if you would rather not pin it, the RFC 1918 set so `permissions status` and `doctor` report `no allowlist visible`, which means *not visible*, not
`"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor` reports `local network access` *not set*. `sudo gitea-macos-runner permissions status` answers definitively. `permissions grant`
as a **pass** once it can see an allowlist. Both keys are documented by Apple in does not have this problem: it re-reads the file with the sudo credentials it just used, so it
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy) confirms its own write.
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 `grant` has two methods. Both are one command; neither needs anything pasted.
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 **`--method allowlist` (the default) is what a CI host wants.** It writes a subnet allowlist — the
gitea-macos-runner vm boot --image default 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.
and click **Allow**. Do not wait for the LaunchAgent to hit it — a background agent has no way to 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
answer the prompt. 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 — but only when it can see it at all, which means
running under `sudo` or straight after a grant. Both keys are documented by Apple in
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
> **Caveat with ad-hoc signing.** An interactive grant is not durable for this project's ad-hoc **`--method prompt` takes effect immediately, with no reboot**, and is the better choice on a Mac
> signed bundle. Local Network privacy does not use TCC; per TN3179 it "uses your main executable you are sitting in front of. It launches the installed `.app` through LaunchServices — which is what
> UUID as part of its implementation", and the linker mints a fresh `LC_UUID` on essentially every makes the app its own responsible process — provokes the real system alert, and reports whether the
> rebuild. So `make install` after a code change is liable to present as a new app that must be grant took. It needs `make install` to have run, a GUI session to show the alert in, and a Developer
> approved again — and macOS offers no way to reset a Local Network decision back to undetermined, ID signature for the grant to survive the next rebuild; `permissions status` reports that last one.
> 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. **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
nothing is broken. It cannot be pre-approved. But the two obvious ways to trigger the prompt both
miss:
- **From the LaunchAgent.** A background agent has no UI, so the prompt has nowhere to appear. The
connection is simply denied, and it surfaces as `No route to host` (errno 65) — not as a
permission error.
- **By hand from a Terminal**, e.g. `gitea-macos-runner vm boot --image default`. macOS assigns the
privacy decision to the *responsible process*, and a binary exec'd from a shell is Terminal's
responsibility, not its own. So both the prompt and the Settings row belong to **Terminal**, and
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".)
`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.
--- ---
@@ -446,6 +549,7 @@ The checks, in order:
| `host capability` | Apple Silicon, and host macOS ≥ 26 | | `host capability` | Apple Silicon, and host macOS ≥ 26 |
| `Virtualization.framework` | `VZVirtualMachine.isSupported` | | `Virtualization.framework` | `VZVirtualMachine.isSupported` |
| `virtualization entitlement` | `com.apple.security.virtualization` on the *running* executable — this is the check that catches running from `.build/` instead of the signed `.app` | | `virtualization entitlement` | `com.apple.security.virtualization` on the *running* executable — this is the check that catches running from `.build/` instead of the signed `.app` |
| `code identity` | The bundle's signature. Passes naming the identifier, team, and hardened runtime; warns on an ad-hoc signature, because that is what makes Local Network grants evaporate on every rebuild ([Code signing](#code-signing)) |
| `configuration` | The config file loads, parses, and passes validation | | `configuration` | The config file loads, parses, and passes validation |
| `free disk space` | Free space on the `storeDir` volume against `storage.minFreeDiskGB` | | `free disk space` | Free space on the `storeDir` volume against `storage.minFreeDiskGB` |
| `login.keychain unlocked` | `security show-keychain-info login.keychain` | | `login.keychain unlocked` | `security show-keychain-info login.keychain` |
@@ -453,7 +557,8 @@ 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` | Passes when a subnet allowlist is set; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) | | `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. Fix either with `permissions grant` (§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
+328 -35
View File
@@ -23,9 +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/…` | | 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`; `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; 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` | | 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 | | 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 | `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 | | `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 |
| `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision <name>` | | `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision <name>` |
@@ -33,6 +36,10 @@ gitea-macos-runner service status
| `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing | | `doctor` says `runner download url → 403` but the URL works in a browser | Old build: the check used `HEAD`, and the presigned redirect target is signed per method | Upgrade — the check now uses a ranged `GET`. If it persists, the asset really is missing |
| Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` | | Disk filling up | Copy-on-write clones grow as jobs write | Raise `storage.minFreeDiskGB`; delete stale clones in `storeDir/vms` |
| `image build` appears to hang during install | Normal — macOS install is slow | Wait. **Do not stop the VM mid-install**; if you did, delete the image and rebuild | | `image build` appears to hang during install | Normal — macOS install is slow | Wait. **Do not stop the VM mid-install**; if you did, delete the image and rebuild |
| `image build` prints its banner and then nothing, at 0% CPU | Old build: the build task was queued behind the `NSApplication` run loop and never started | Upgrade — the task is now detached. Stage lines should appear within seconds |
| `image build` stuck at `loading restore image metadata…` | A truncated or partial `.ipsw` — the framework blocks rather than failing | Upgrade (the file is now size- and magic-checked first); re-download the IPSW |
| `provisioning failed: … Failed to lock auxiliary storage` after install | The installer's VM had not yet released `nvram.bin` when first boot started | Upgrade — the install now drains its queue and first boot retries for 30 s. Re-run `image build`; it resumes |
| `image build` says `image 'default' already exists` after a failed first boot | Old build: an installed-but-unprovisioned bundle was treated as a finished image | Upgrade — `image build` now resumes it instead of refusing |
--- ---
@@ -56,8 +63,9 @@ codesign -d --entitlements - ~/Applications/GiteaMacosRunner.app # verify
The output must list `com.apple.security.virtualization`. Then confirm the command you're running The output must list `com.apple.security.virtualization`. Then confirm the command you're running
resolves to the installed bundle's binary — `which -a gitea-macos-runner` should point at resolves to the installed bundle's binary — `which -a gitea-macos-runner` should point at
`~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`, not at `~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner`, not at
`.build/release/gitea-macos-runner`. Ad-hoc signing is sufficient; you do not need a paid developer `.build/release/gitea-macos-runner`. Ad-hoc signing is sufficient for *this* error; you do not need
account. a developer account to start VMs. (A Developer ID certificate solves a different problem — Local
Network grants evaporating on rebuild. See [setup.md](setup.md#code-signing).)
--- ---
@@ -97,23 +105,28 @@ 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: pre-authorize the VM subnet, then **reboot** (these are read at boot): 2. No entry at all: check and fix the permission.
```sh ```sh
sudo defaults write com.apple.network.local-network \ sudo gitea-macos-runner permissions status # sudo: the allowlist lives in root's preferences
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24" gitea-macos-runner permissions grant # then reboot when it offers
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. `doctor` reports `local network Without `sudo`, `status` reports `no allowlist visible` on every host — it is written as root into
access` as a pass once it can see this. This is the deterministic fix for an unattended host — `/var/root/Library/Preferences/`, which an ordinary login cannot read. That is not evidence the
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings) grant is missing. `grant` itself does not have the problem: it verifies its own write.
for why the interactive grant is not.
3. Or grant it interactively: run `gitea-macos-runner vm boot --image default` from a Terminal in `grant` pre-authorizes the VM subnets and offers to reboot, which is required — the allowlist is
the GUI session and click **Allow**. The app is not listed under **System Settings → Privacy & read at boot. It grants all of RFC 1918 by default; `--subnet` narrows it, but nothing narrower
Security → Local Network** until it has made that first attempt. 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.
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.
--- ---
@@ -133,35 +146,61 @@ privacy controls, Local Network privacy is not stored in TCC — per
the checks live "deep in the networking stack" as a Network Extension packet filter, so the 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. 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: **Why you will probably never see the app's own row.** macOS assigns a privacy decision to the
*responsible process*, not necessarily to the binary that opened the socket. Launching the runner
the obvious way —
```sh ```sh
gitea-macos-runner vm boot --image default 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 — execs the bundle's binary from a shell, so the system holds **Terminal** responsible. Both the
trigger it; a background agent cannot answer the prompt, so it simply fails to reach the guest. 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.
**Fix — deterministic, and what to use on a CI box.** Allowlist the VM subnet instead. It is keyed **Fix — deterministic, and what to use on any host running the LaunchAgent.** Allowlist the VM
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 ```sh
sudo defaults write com.apple.network.local-network \ gitea-macos-runner permissions grant
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 That asks for your sudo password, writes both of Apple's keys (documented in TN3179 — the same pair
Apple's, documented in TN3179; the same pair is what [Tart's FAQ](https://tart.run/faq/) recommends [Tart's FAQ](https://tart.run/faq/) recommends for this exact problem on CI hosts), reports which
for this exact problem on CI hosts. 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**.
> **Why the interactive grant does not stick here.** This project ships an **ad-hoc signed** bundle **On a Mac you are sitting at,** `permissions grant --method prompt` is the alternative, and it
> (`codesign --sign -`), and TN3179 notes that "local network privacy uses your main executable UUID needs no reboot. It launches the installed `.app` through LaunchServices instead of exec'ing it from
> as part of its implementation". The linker writes a new `LC_UUID` on essentially every rebuild, so the shell, which is exactly what makes the app its own responsible process — so the alert, and the
> a rebuilt-and-reinstalled runner can read as a *different* program and prompt again — while the Settings row it creates, belong to the app rather than to Terminal, and the decision applies to the
> old row lingers, since macOS provides no way to reset a Local Network decision to undetermined. LaunchAgent. It requires `make install` to have run, a GUI session, and a Developer ID signature to
> Expect duplicate entries after a few upgrades. The subnet allowlist avoids all of this. 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
> team anchor, so that UUID *is* the app's identity — and the linker writes a new `LC_UUID` on
> essentially every rebuild, so a rebuilt-and-reinstalled runner reads as a *different* program and
> prompts again, while the old row lingers (macOS provides no way to reset a Local Network decision
> to undetermined). Expect duplicate entries after a few upgrades.
>
> Signing with a **Developer ID Application** certificate fixes the churn: its designated
> requirement is anchored to your team, so every build is recognised as the same program. `make sign`
> uses one automatically when it is in the keychain — check with `codesign -dvv` or `doctor`'s
> `code identity` line, and see [setup.md](setup.md#code-signing). The subnet allowlist avoids the
> mechanism entirely and is still the right answer for an unattended host.
--- ---
@@ -187,6 +226,63 @@ with this builder.
--- ---
## The daemon boots VMs forever and every teardown says `reason=cancelled`
**Symptom.** A job is queued, the daemon is running, and the log repeats the same three lines with a
new runner name each time — but no runner ever appears in Gitea:
```
info orchestrator: job=1 runner=macos-vm-1cd8e83f… slot=0 booting VM
info orchestrator: ip=192.168.65.233 slot=0 guest leased address
info orchestrator: reason=cancelled slot=0 tearing down slot
```
Note what is missing: nothing between the lease and the teardown, and a teardown reason that names
no cause.
**Cause.** The guest takes longer to reach `sshd` than `scheduler.bootTimeoutSeconds` allows, so the
scheduler tears the slot down while it is still coming up — usually seconds before it would have
succeeded. This is not a timeout that fires once; it is a **livelock**. The replacement clone starts
from zero *and* adds load to an already contended host, so the next boot is slower still and the
loop never converges.
Several Virtualization guests on one Mac is enough to cause it: a guest that reaches SSH in 40
seconds on an idle host can take four or five minutes when it is sharing the machine, and each slot
holds 4 vCPU and 8 GB for the whole attempt. Check with `uptime` inside a guest — a load average in
the tens means the guest is starved, not broken.
**Fix.**
1. Raise `scheduler.bootTimeoutSeconds`. The default is 900; treat it as a ceiling on the guest's
*worst* case, not its typical one. Timing out too early costs far more than noticing a genuinely
wedged guest late.
2. Reduce contention. Count what is actually running:
```sh
ps -Ao pid,rss,etime,comm | grep -i -e virtual -e vmnet
```
Virtualization guests belonging to *other* tools compete for the same cores and the same two-VM
macOS limit. Shut down what you are not using, or lower `scheduler.maxConcurrentVMs`.
3. Confirm the guest itself is fine, independently of the daemon, with `doctor` — its `guest ssh`
check authenticates against whichever slot currently holds a lease:
```sh
gitea-macos-runner doctor
```
**If you are on an older build**, upgrade: the empty gap in that log was three bugs, all now fixed.
`waitForSSH` was handed the full `bootTimeoutSeconds` even though the scheduler's clock had started
before the clone — so the scheduler always fired first and `waitForSSH`'s own error was unreachable;
its per-attempt failures were only ever reported in that unreachable error; and the teardown
overwrote the planner's reason with `cancelled`. Current builds log `waiting for guest ssh` with the
attempt count and the last error while it is happening, and report
`reason="boot timeout: provisioning for 312s (limit 300s)"`.
---
## `SecKeyCreateRandomKey` / "Interaction is not allowed" ## `SecKeyCreateRandomKey` / "Interaction is not allowed"
**Symptom.** The daemon starts but fails during VM setup with a Security-framework error mentioning **Symptom.** The daemon starts but fails during VM setup with a Security-framework error mentioning
@@ -363,6 +459,72 @@ concurrent clones diverging.
--- ---
## `image build` prints the banner and then nothing at all
**Symptom.** `image build` prints
```
building image 'default' (this takes a while; the IPSW alone is ~15 GB)
```
and then stops — no stage lines, no progress bar, no error. Activity Monitor shows the process
using no CPU and no VM services running. Ctrl-C does nothing.
**Cause.** A bug in releases before this fix. `image build` hosts an `NSApplication` run loop
(Virtualization.framework requires one), and the work was started with a `Task` that inherited the
main actor. Because `NSApplication.run()` is itself reached from Swift's async `main`, the main
dispatch queue already had a block in flight and would not re-enter — so the build task was queued
behind a run loop that never yields and never got a first tick. Nothing ran, including the code
that would have reported the error. `SIGINT`/`SIGTERM` handling was stuck the same way, which is
why Ctrl-C did not work either.
**Fix.** Upgrade. The build task is now detached and signals are handled off the main queue. You
should see stage lines within a second or two:
```
resolving restore image…
loading restore image metadata…
creating VM bundle (disk 64 GB)…
installing macOS [########----------------------] 27%
```
If a build still goes quiet, the line last printed tells you which stage owns the silence — see
below.
---
## `image build` sits at "loading restore image metadata…"
**Symptom.** The build reaches `loading restore image metadata…` and stays there. After a minute it
adds:
```
still loading — a truncated or partially downloaded .ipsw can block here; verify the download completed
```
**Cause.** `VZMacOSRestoreImage.image(from:)` reads the whole archive's metadata and reports no
progress while it does. On a healthy ~21 GB IPSW this takes seconds to a minute or two. On a
*partial* download it can block for a very long time instead of failing.
**Fix.** The obvious checks are now made before the framework is handed the file — it must exist, be
a regular file, be at least 1 GB, and start with the zip magic `PK` — so an incomplete download now
fails immediately with its actual size rather than hanging. If you are on an older build, check by
hand:
```sh
ls -la ~/Downloads/UniversalMac_*.ipsw # ~15-22 GB, no .download/.crdownload sibling
xxd -l 2 -p ~/Downloads/UniversalMac_*.ipsw # must print 504b
```
Anything smaller, or not starting `504b`, is an incomplete or wrong file: delete it and download it
again.
> A pattern that matches **more than one** IPSW is refused outright, listing the matches — for
> example `~/Downloads/UniversalMac_27.0_*.ipsw` when two 27.0 builds are sitting in `~/Downloads`.
> Name exactly one of them.
---
## `image build` hangs at install ## `image build` hangs at install
**Symptom.** `image build` sits for a long time at the macOS install phase with little visible **Symptom.** `image build` sits for a long time at the macOS install phase with little visible
@@ -373,7 +535,7 @@ minutes, longer on slower storage). The install phase is largely silent.
**Fix.** **Wait, and do not stop the VM mid-install.** Interrupting the installer leaves the disk **Fix.** **Wait, and do not stop the VM mid-install.** Interrupting the installer leaves the disk
image in an undefined state; the resulting image may boot and then fail in confusing ways later. image in an undefined state; the resulting image may boot and then fail in confusing ways later.
There is no resume. There is no resume from a *partial* install — only from a complete one (see the next section).
If you did interrupt it, or the build genuinely failed: If you did interrupt it, or the build genuinely failed:
@@ -385,3 +547,134 @@ gitea-macos-runner image build --ipsw <path> --name <name>
Before rebuilding, verify the IPSW is complete and matches your host architecture (Apple Silicon) Before rebuilding, verify the IPSW is complete and matches your host architecture (Apple Silicon)
and version requirement (macOS 27+ for unattended provisioning), and that you have enough free disk and version requirement (macOS 27+ for unattended provisioning), and that you have enough free disk
for the IPSW plus the target disk size. for the IPSW plus the target disk size.
---
## `Failed to lock auxiliary storage` right after the install finishes
**Symptom.** The macOS install runs to 100%, then:
```
first boot + guest provisioning…
error: provisioning failed: could not boot image 'default': provisioning failed: Invalid
virtual machine configuration. Failed to lock auxiliary storage.
```
**Cause.** A `VZVirtualMachine` holds an exclusive lock on its bundle's `nvram.bin` for its entire
lifetime and releases it in `dealloc`. `VZMacOSInstaller` owns a VM of its own, and older builds
resumed the caller from inside the installer's completion handler — before the framework's frame
had unwound and dropped the last reference. First boot then constructed a *second* VM over the same
bundle and lost the race.
**Fix.** Upgrade. Two changes address it:
- The install now tears down its VM on its own serial queue and resumes the caller only from a
later block on that queue, so the installer's VM is deallocated before `install()` returns.
- First boot retries specifically on this failure for up to 30 s (2 s apart), printing
`waiting for installer to release the VM bundle…`. Any other configuration error still fails
immediately — an invalid configuration does not become valid by waiting.
Nothing is lost when it does happen: the install is complete, so re-running `image build` resumes.
---
## `image build` resumes an installed-but-unprovisioned image
**Symptom.** A previous `image build` finished installing macOS and then failed at first boot or
provisioning. Re-running it prints:
```
image default already installed — resuming first boot + provisioning
```
**This is intended.** An `image build` that fails after the install has left an hour of work on
disk, and throwing it away to redo an identical install is not a reasonable default. When the
bundle exists, is complete, and is not yet marked provisioned, the install phase is skipped and the
build goes straight to first boot.
It is safe because provisioning is exactly what did *not* happen: the guest has never been booted,
so its first boot is still ahead of it and `VZMacGuestProvisioningOptions` — which macOS evaluates
only on the first boot after a restore — still applies.
**The one ambiguous case.** The bundle on disk cannot say whether an earlier run got far enough to
*boot* the guest. If it did, that single chance at automated Setup Assistant is spent. Rather than
guess, the resume boots and waits for SSH: if the guest answers, provisioning was never applied and
the build continues normally. Only if SSH times out does it stop, and it then tells you so
explicitly — that state is unrecoverable, and the fix is `image delete` followed by a fresh build.
An image that is already `provisioned` is untouched; `image build` still refuses with
`image '<name>' already exists`. To re-run provisioning on a finished image, use
`gitea-macos-runner image provision <name>` instead.
---
## SSH fails with "No route to host" (errno 65) mid-run
**Symptom.** A command that had *just* talked to the guest successfully suddenly cannot reach it —
most visibly `image provision`, which waits for SSH, reports the first provisioning step, and then
dies on the upload:
```
first boot + guest provisioning…
provisioning: system configuration (provision.sh)
error: provisioning failed: could not upload provision.sh from …/provision.sh:
ssh failed: cannot connect to 192.168.65.232:22: … No route to host) (errno: 65)
```
**First, check whether it is actually failing.** A single errno 65 at `attempt=1 elapsed=0s`,
seconds after `guest leased address`, is normal and not worth chasing. `waitForSSH` logs its first
probe unconditionally and then heartbeats every 30 s, and that first probe usually lands before the
host has an ARP entry for the guest — which is also `EHOSTUNREACH`. What matters is whether the
message *repeats* at `elapsed=30s`, `60s`, `90s`, … If it does not, boot is proceeding normally. If
it does, read on.
**Cause.** On macOS 15 and newer, an app that has not been granted Local Network access does not get
a "permission denied": the packet filter answers **`EHOSTUNREACH` — errno 65, "No route to host"**,
which is indistinguishable from a guest that is genuinely off the network. Guests here live on a
host-private NAT link that is reachable whenever the VM is up, so on this path a *persistent* errno
65 is far more often the privacy filter than a routing problem.
Two details make it look intermittent rather than like a permission problem:
- **Under an ad-hoc signature the grant is keyed on the executable's UUID.** Per
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy),
Local Network privacy "uses your main executable UUID as part of its implementation", and the
linker mints a fresh `LC_UUID` on essentially every build. A `make install` after a code change
therefore presents a program macOS has never seen, whose permission is undetermined again — even
though the binary you ran ten minutes ago worked. A Developer ID signature anchors identity to the
team instead and does not drift; `doctor`'s `code identity` check tells you which you have, and
[setup.md](setup.md#code-signing) covers switching.
- **Processes started over SSH are exempt.** Running the same command through `ssh you@host …`
succeeds while running it from a GUI Terminal fails. A remote-shell success proves nothing about
the interactive path.
- **A shell-launched run is attributed to Terminal.** macOS charges the privacy decision to the
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 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
gitea-macos-runner permissions grant
```
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.
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
`image provision` is idempotent, so a partially completed run is safe to repeat.