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

This commit is contained in:
2026-08-07 15:41:01 -07:00
parent 6b0c01b74b
commit 902bea5091
11 changed files with 450 additions and 91 deletions
+7 -4
View File
@@ -44,10 +44,13 @@ jobs:
- name: unit tests - name: unit tests
run: swift test run: swift test
# Release build, .app assembly, ad-hoc signature. `codesign --sign -` # Release build, .app assembly, signature. `make sign` looks for a
# needs no signing identity and no keychain, so it works unattended in a # Developer ID identity and falls back to ad-hoc when there is none — which
# throwaway guest — which is the same property that makes it the # is always the case here, since this runs in a throwaway guest with no
# project's shipping signature. # 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 - name: build and sign the app bundle
run: make all run: make all
+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:
+9 -6
View File
@@ -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
@@ -145,7 +148,7 @@ 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]` | Write and load `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist`. Also evicts any agent left behind under a previous label. |
| `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. |
| `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. |
+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>
+132 -24
View File
@@ -78,25 +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. **Guest SSH**, against whichever slot currently holds a DHCP lease — /// 11. **Guest SSH**, against whichever slot currently holds a DHCP lease —
/// the one check that exercises host → vmnet → guest `sshd` → password /// the one check that exercises host → vmnet → guest `sshd` → password
/// auth end to end. Informational when no guest is up, since `doctor` /// auth end to end. Informational when no guest is up, since `doctor`
/// will not boot one. /// will not boot one.
/// 11. **Local Network privacy**. Passes when a subnet allowlist is set in /// 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
@@ -160,12 +164,13 @@ public enum Doctor {
} }
/// 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(),
] ]
} }
@@ -193,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",
@@ -232,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"
@@ -696,14 +803,15 @@ public enum Doctor {
detail: "guests are reached over the host-private NAT link", detail: "guests are reached over the host-private NAT link",
remediation: """ remediation: """
on macOS 15+ the first connection to a guest can be blocked by the Local Network \ on macOS 15+ the first connection to a guest can be blocked by the Local Network \
privacy prompt, which a background LaunchAgent cannot answer. 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. Prefer the subnet \
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \ allowlist: it needs no prompt, covers every process, and survives rebuilds. \
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \ sudo defaults write com.apple.network.local-network \
"192.168.64.0/18" (then reboot). See docs/setup.md §2.6. AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
""" """
) )
} }
+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])
@@ -133,7 +133,7 @@ enum VZAppRuntime {
/// Signals land here rather than on `.main`. See ``run(onSignal:body:)``. /// Signals land here rather than on `.main`. See ``run(onSignal:body:)``.
private static let signalQueue = DispatchQueue( private static let signalQueue = DispatchQueue(
label: "xyz.blakeslee.gitea-macos-runner.signals") 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.
/// ///
@@ -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 \
@@ -46,6 +46,13 @@ 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)")
@@ -68,7 +75,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))")
} }
} }
+51 -7
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 │
│ │ │ │ │ │
@@ -382,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
@@ -527,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
@@ -550,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
@@ -589,3 +610,26 @@ 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
there is no reliable way to grant it interactively here.** 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.
`Doctor.checkLocalNetwork` reads those keys and requires coverage of
`192.168.64.0/18`, not a single /24, because the NAT subnet is chosen at runtime
and slides to the next free /24. `SSHExec.localNetworkHint` appends the same
guidance to connection failures, since errno 65 gives the operator nothing to go
on by itself.
+80 -19
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.
@@ -420,25 +473,31 @@ a **warning** when an allowlist exists but does not. Both keys are documented by
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy) [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
and are the workaround [Tart's FAQ](https://tart.run/faq/) recommends for the same problem. 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 **Why not just approve the prompt?** Because on this host there is usually nothing able to show it,
Settings → Privacy & Security → Local Network** only *after* it has actually attempted a connection and when something does, it is attributed to the wrong program.
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 An app appears under **System Settings → Privacy & Security → Local Network** only *after* it has
gitea-macos-runner vm boot --image default 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:
and click **Allow**. Do not wait for the LaunchAgent to hit it — a background agent has no way to - **From the LaunchAgent.** A background agent has no UI, so the prompt has nowhere to appear. The
answer the prompt. 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".)
> **Caveat with ad-hoc signing.** An interactive grant is not durable for this project's ad-hoc > **And under ad-hoc signing it is not durable anyway.** Local Network privacy does not use TCC; per
> signed bundle. Local Network privacy does not use TCC; per TN3179 it "uses your main executable > TN3179 it "uses your main executable UUID as part of its implementation", and the linker mints a
> UUID as part of its implementation", and the linker mints a fresh `LC_UUID` on essentially every > fresh `LC_UUID` on essentially every rebuild. So `make install` after a code change presents as a
> rebuild. So `make install` after a code change is liable to present as a new app that must be > new app that must be approved again — and macOS offers no way to reset a Local Network decision
> approved again — and macOS offers no way to reset a Local Network decision back to undetermined, > back to undetermined, so stale entries accumulate. Signing with a Developer ID certificate fixes
> so the stale entries accumulate. This is why the allowlist above, which is keyed on the subnet > the churn (see [Code signing](#code-signing)); the subnet allowlist above, keyed on the network
> rather than on the app, is the recommendation for an unattended machine. > rather than on the app, sidesteps the whole mechanism and is the recommendation for an unattended
> machine either way.
--- ---
@@ -463,6 +522,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` |
@@ -470,6 +530,7 @@ The checks, in order:
| `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on | | `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on |
| `runner download url` | The `gitea-runner` release asset is reachable | | `runner download url` | The `gitea-runner` release asset is reachable |
| `token file permissions` | Warns — not fails — when a token file is group- or world-readable | | `token file permissions` | Warns — not fails — when a token file is group- or world-readable |
| `guest ssh` | Reachability of the most recent guest lease, when there is one. Warns on a timeout, which is most often a stale 24-hour lease for a guest that is already gone |
| `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) | | `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt (§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
+48 -21
View File
@@ -24,7 +24,7 @@ gitea-macos-runner service status
| VM won't start; entitlement / `com.apple.security.virtualization` error | Running an unsigned binary, or one outside the signed `.app` bundle | `make sign` (or re-run `make install`); invoke the installed bundle, never `.build/release/…` | | VM won't start; entitlement / `com.apple.security.virtualization` error | Running an unsigned binary, or one outside the signed `.app` bundle | `make sign` (or re-run `make install`); invoke the installed bundle, never `.build/release/…` |
| `virtualMachineLimitExceeded` at boot | macOS allows at most **2** concurrent macOS VMs | Set `scheduler.maxConcurrentVMs` ≤ 2; kill stray VMs from earlier runs | | `virtualMachineLimitExceeded` at boot | macOS allows at most **2** concurrent macOS VMs | Set `scheduler.maxConcurrentVMs` ≤ 2; kill stray VMs from earlier runs |
| VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; grant Local Network permission or pre-authorize the subnet | | VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; grant Local Network permission or pre-authorize the subnet |
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection; it cannot be pre-approved | Boot one VM by hand from a GUI Terminal to create the entry, or (better on CI) allowlist the subnet with `defaults write com.apple.network.local-network` | | Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection, and a run started from a shell is attributed to Terminal, not to the app | Allowlist the subnet with `defaults write com.apple.network.local-network` and reboot; the interactive grant does not reach the LaunchAgent |
| 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) | | VMs boot in a loop; every teardown says `reason=cancelled` and nothing is logged between the lease and the teardown | `scheduler.bootTimeoutSeconds` is below the guest's *worst-case* boot on a contended host, so each clone is killed while still starting — and each replacement makes the next one slower | Raise `scheduler.bootTimeoutSeconds` (default 900) and reduce the number of concurrent guests; see [The daemon boots VMs forever](#the-daemon-boots-vms-forever-and-every-teardown-says-reasoncancelled) |
| `ssh failed: cannot connect … No route to host) (errno: 65)` part-way through provisioning | macOS 15+ Local Network privacy blocking the app — the grant is keyed on the executable's UUID, so `make install` withdraws it | Allowlist the subnet (`192.168.64.0/18`) and **reboot**; see [SSH fails with "No route to host" mid-run](#ssh-fails-with-no-route-to-host-errno-65-mid-run) | | `ssh failed: cannot connect … No route to host) (errno: 65)` part-way through provisioning | macOS 15+ Local Network privacy blocking the app — the grant is keyed on the executable's UUID, so `make install` withdraws it | Allowlist the subnet (`192.168.64.0/18`) and **reboot**; see [SSH fails with "No route to host" mid-run](#ssh-fails-with-no-route-to-host-errno-65-mid-run) |
@@ -63,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).)
--- ---
@@ -120,9 +121,10 @@ prompt**: a LaunchAgent that was never granted permission (or was denied) cannot
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings) see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
for why the interactive grant is not. for why the interactive grant is not.
3. Or grant it interactively: run `gitea-macos-runner vm boot --image default` from a Terminal in Do not reach for the interactive grant instead. Booting a VM by hand from a Terminal makes the
the GUI session and click **Allow**. The app is not listed under **System Settings → Privacy & prompt (and the **System Settings → Privacy & Security → Local Network** row) belong to
Security → Local Network** until it has made that first attempt. *Terminal* rather than to this app, and approving it there does not carry over to the
LaunchAgent.
--- ---
@@ -142,17 +144,24 @@ 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. Between the two, there is no reliable way to grant
this interactively on an unattended host.
**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: subnet instead. It 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 \ sudo defaults write com.apple.network.local-network \
@@ -165,12 +174,18 @@ Reboot afterwards. `doctor` then reports `local network access` as a **pass**. B
Apple's, documented in TN3179; the same pair is what [Tart's FAQ](https://tart.run/faq/) recommends Apple's, documented in TN3179; the same pair is what [Tart's FAQ](https://tart.run/faq/) recommends
for this exact problem on CI hosts. for this exact problem on CI hosts.
> **Why the interactive grant does not stick here.** This project ships an **ad-hoc signed** bundle > **And an ad-hoc signed bundle cannot hold the grant anyway.** TN3179 notes that "local network
> (`codesign --sign -`), and TN3179 notes that "local network privacy uses your main executable UUID > privacy uses your main executable UUID as part of its implementation". An ad-hoc signature has no
> as part of its implementation". The linker writes a new `LC_UUID` on essentially every rebuild, so > team anchor, so that UUID *is* the app's identity — and the linker writes a new `LC_UUID` on
> a rebuilt-and-reinstalled runner can read as a *different* program and prompt again — while the > essentially every rebuild, so a rebuilt-and-reinstalled runner reads as a *different* program and
> old row lingers, since macOS provides no way to reset a Local Network decision to undetermined. > prompts again, while the old row lingers (macOS provides no way to reset a Local Network decision
> Expect duplicate entries after a few upgrades. The subnet allowlist avoids all of this. > 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.
--- ---
@@ -591,23 +606,35 @@ 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) 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 **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"**, 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 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 errno 65 is far more host-private NAT link that is reachable whenever the VM is up, so on this path a *persistent* errno
often the privacy filter than a routing problem. 65 is far more often the privacy filter than a routing problem.
Two details make it look intermittent rather than like a permission problem: Two details make it look intermittent rather than like a permission problem:
- **The grant is keyed on the executable's UUID.** Per - **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), [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 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 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 therefore presents a program macOS has never seen, whose permission is undetermined again — even
though the binary you ran ten minutes ago worked. 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 …` - **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 succeeds while running it from a GUI Terminal fails. A remote-shell success proves nothing about
the interactive path. 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 subnet — it is keyed on the network, not on the app, so no rebuild can **Fix.** Allowlist the subnet — it is keyed on the network, not on the app, so no rebuild can
withdraw it and no prompt has to be answered: withdraw it and no prompt has to be answered: