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

This commit is contained in:
2026-08-07 16:38:56 -07:00
parent 902bea5091
commit 26739f9487
12 changed files with 1214 additions and 174 deletions
+5 -1
View File
@@ -87,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
``` ```
@@ -148,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-vm-orchestrator.plist`. Also evicts any agent left behind under a previous label. | | `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. |
| `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. |
+218
View File
@@ -0,0 +1,218 @@
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.
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
/// Whether anything is configured at all.
public var isConfigured: Bool { !allowlist.isEmpty }
public init(allowlist: [String], sourcePaths: [String], coversGuestRange: Bool) {
self.allowlist = allowlist
self.sourcePaths = sourcePaths
self.coversGuestRange = coversGuestRange
}
}
/// Reads the host's current allowlist.
///
/// Best effort and never fatal: an unreadable or absent preferences file
/// simply reads as "no allowlist".
public static func status() -> Status {
var found: [String] = []
var sources: [String] = []
for path in preferenceCandidates() {
guard let data = FileManager.default.contents(atPath: path),
let plist = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil) as? [String: Any]
else { continue }
var contributed = false
for key in keys {
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
found.append(entry)
contributed = true
}
}
if contributed { sources.append(path) }
}
return Status(
allowlist: found,
sourcePaths: sources,
coversGuestRange: found.contains(where: coversVMNetRange)
)
}
/// 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
}
}
+11 -10
View File
@@ -351,12 +351,16 @@ enum SSHTransportError: Error {
/// that errno is more often the privacy filter than a routing failure — /// that errno is more often the privacy filter than a routing failure —
/// worth naming rather than leaving the operator to guess. /// worth naming rather than leaving the operator to guess.
/// ///
/// It matters most right after a rebuild. Per /// 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) /// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
/// the grant "uses your main executable UUID", and the linker mints a fresh /// the grant "uses your main executable UUID" when there is no stable
/// `LC_UUID` on essentially every build — so `make install` can present a /// designated requirement to key on, and the linker mints a fresh `LC_UUID`
/// program macOS has never seen, whose permission is undetermined again, /// on essentially every build — so `make install` can present a program
/// even though the previous binary worked minutes earlier. /// 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 { static func localNetworkHint(for underlying: any Error) -> String {
let text = "\(underlying)".lowercased() let text = "\(underlying)".lowercased()
guard text.contains("errno: 65") || text.contains("no route to host") guard text.contains("errno: 65") || text.contains("no route to host")
@@ -365,11 +369,8 @@ enum SSHTransportError: Error {
return """ return """
(on macOS 15+ this is also what Local Network privacy returns when \ (on macOS 15+ this is also what Local Network privacy returns when \
it blocks an app — and the grant is keyed on the executable's UUID, \ it blocks an app. Check and fix it with `gitea-macos-runner \
so every rebuild withdraws it. Pre-authorize the guest subnet \ permissions status` / `permissions grant`. \
instead: sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" — same \
for AllowedWiFiLocalNetworkAddresses — then reboot. \
See docs/troubleshooting.md) See docs/troubleshooting.md)
""" """
} }
+12 -83
View File
@@ -771,28 +771,27 @@ public enum Doctor {
/// does not apply (Apple, TN3179). /// does not apply (Apple, TN3179).
public static func localNetworkNote() -> DoctorCheck { public static func localNetworkNote() -> DoctorCheck {
let name = "local network access" let name = "local network access"
let allowed = localNetworkAllowlist() let status = LocalNetworkPolicy.status()
if !allowed.isEmpty {
if allowed.contains(where: coversVMNetRange) { if status.isConfigured {
if status.coversGuestRange {
return DoctorCheck( return DoctorCheck(
name: name, name: name,
result: .pass, result: .pass,
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))" detail: "subnet allowlist set: \(status.allowlist.joined(separator: ", "))"
) )
} }
return DoctorCheck( return DoctorCheck(
name: name, name: name,
result: .warn, result: .warn,
detail: "subnet allowlist set but does not cover the guest range: " detail: "subnet allowlist set but does not cover the guest range: "
+ allowed.joined(separator: ", "), + status.allowlist.joined(separator: ", "),
remediation: """ remediation: """
Virtualization.framework's NAT does not stay on 192.168.64.0/24 — it moves \ 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 \ 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 \ 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 to \ and every guest connection then fails with "No route to host". Widen it: \
cover the whole span: sudo defaults write com.apple.network.local-network \ `gitea-macos-runner permissions grant`, 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.
""" """
) )
} }
@@ -807,84 +806,14 @@ public enum Doctor {
has no UI to show it in; a run started from a shell is attributed to the \ has no UI to show it in; a run started from a shell is attributed to the \
*responsible* process, so both the prompt and the System Settings → Privacy & \ *responsible* process, so both the prompt and the System Settings → Privacy & \
Security → Local Network row belong to Terminal rather than to this app — and \ Security → Local Network row belong to Terminal rather than to this app — and \
granting it to Terminal does not carry over to the LaunchAgent. Prefer the subnet \ granting it to Terminal does not carry over to the LaunchAgent. Grant it with \
allowlist: it needs no prompt, covers every process, and survives rebuilds. \ `gitea-macos-runner permissions grant`, which writes a subnet allowlist that \
sudo defaults write com.apple.network.local-network \ needs no prompt, covers every process, and survives rebuilds — then reboot. \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \ `permissions status` explains both routes. See docs/setup.md §2.6.
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
""" """
) )
} }
/// 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.
static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0
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.
static func coversVMNetRange(_ entry: String) -> Bool {
let parts = entry.split(separator: "/", maxSplits: 1)
guard let base = ipv4Value(String(parts[0])) else { return false }
let prefix = parts.count == 2 ? Int(parts[1]) : 32
guard let prefix, (0...32).contains(prefix) else { return false }
let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix)
let network = base & mask
let broadcast = network | ~mask
return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress
}
/// Packs dotted-quad IPv4 into a comparable integer; nil for anything else
/// (an IPv6 entry, a hostname, a typo).
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
}
/// 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
@@ -0,0 +1,347 @@
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)
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.
"""
}
}
}
// 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 arguments in LocalNetworkPolicy.writeArguments(subnets: subnets) {
let sudoArguments = (allowPasswordPrompt ? [] : ["-n"]) + ["/usr/bin/defaults"] + arguments
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
process.arguments = sudoArguments
// Stdio is deliberately inherited rather than piped. sudo reads the
// password from /dev/tty and would work either way, but its prompt
// and any "not in the sudoers file" complaint belong in front of
// the operator, not captured and paraphrased.
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let index = LocalNetworkPolicy.writeArguments(subnets: subnets)
.firstIndex(of: arguments) ?? 0
if !allowPasswordPrompt {
throw PermissionError.needsPassword(commands: commands)
}
throw PermissionError.writeFailed(
command: commands[index], exitCode: process.terminationStatus)
}
}
return AllowlistResult(requested: subnets, observed: LocalNetworkPolicy.status())
}
/// 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 {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
process.arguments = ["/sbin/shutdown", "-r", "now"]
try process.run()
process.waitUntilExit()
}
// 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)
}
}
@@ -0,0 +1,261 @@
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 = LocalNetworkPolicy.status()
if !status.sourcePaths.isEmpty {
print("")
for path in status.sourcePaths {
print("allowlist read from: \(path)")
}
}
guard !status.coversGuestRange else { return }
print("")
print("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.
guard result.verified 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("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
""")
}
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)
}
}
}
@@ -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
@@ -59,8 +69,81 @@ struct ServiceCommand: AsyncParsableCommand {
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.
+1
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
) )
@@ -0,0 +1,142 @@
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"))
}
}
+27 -5
View File
@@ -612,7 +612,7 @@ adopting it would require per-slot saved states and a careful look at DHCP lease
reuse — not a drop-in optimization. reuse — not a drop-in optimization.
**16. macOS 15+ Local Network privacy can block host→guest connections, and **16. macOS 15+ Local Network privacy can block host→guest connections, and
there is no reliable way to grant it interactively here.** Per granting it interactively takes deliberate work.** Per
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy) [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 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 from `TCC.db`, cannot be queried, cannot be reset, and it "uses your main
@@ -628,8 +628,30 @@ rebuild.
(`com.apple.network.local-network`, keys `AllowedEthernetLocalNetworkAddresses` (`com.apple.network.local-network`, keys `AllowedEthernetLocalNetworkAddresses`
and `AllowedWiFiLocalNetworkAddresses`), which is keyed on the network rather 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. than the app and is read at boot — so it needs a reboot, not a service restart.
`Doctor.checkLocalNetwork` reads those keys and requires coverage of `LocalNetworkPolicy` owns the arithmetic and requires coverage of
`192.168.64.0/18`, not a single /24, because the NAT subnet is chosen at runtime `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 and slides to the next free /24; `Doctor.localNetworkNote` and
guidance to connection failures, since errno 65 gives the operator nothing to go `SSHExec.localNetworkHint` both report against it, since errno 65 gives the
on by itself. operator nothing to go on by itself.
→ *Consequence:* both routes are commands rather than documentation.
`permissions grant` writes the allowlist and verifies it read back (`sudo
defaults write` lands in root's or the invoking user's preferences depending on
whether sudo preserved `HOME`, so where it went is not assumable).
`permissions grant --method prompt` addresses the attribution problem head-on:
launching the installed bundle through LaunchServices (`open -n -b …`) makes the
app its **own** responsible process, so the prompt and the Settings row belong to
it rather than to Terminal — and because the LaunchAgent runs the same signed
identity, the grant carries. That only became worth building once the bundle was
Developer ID signed; under ad-hoc signing the UUID churn withdraws it on the next
rebuild, which is why `permissions status` reports code identity alongside the
allowlist.
→ *Consequence:* the prompt route is best-effort and says so. Observed on a host
where the decision was already recorded: `UserEventAgent` resolves the flow to
the bundle ID on every attempt — so the attribution works — but presents no
alert, because macOS asks once per app identity and then answers from that
record, silently, forever. There is no supported reset. So `--method prompt`
verifies by *probing* rather than by trusting the launch, and on a denial says
plainly that it did not take and points at the allowlist, which is not subject
to the per-app check at all. The allowlist stays the recommendation.
+48 -29
View File
@@ -441,6 +441,11 @@ disabling sleep (`sudo pmset -a sleep 0 disablesleep 1`) so the Mac comes back i
after a power event without a human present. This does mean the disk is effectively unlocked at 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
@@ -449,32 +454,41 @@ Starting with macOS 15, a process that contacts other hosts on the local network
one-time Local Network permission prompt. A LaunchAgent that is denied (or that never gets a human 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/18" gitea-macos-runner permissions grant # configure it
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
``` ```
Then **reboot** — these are read at boot, so restarting the service alone is not enough. `grant` has two methods. Both are one command; neither needs anything pasted.
**Do not pin this to a single /24.** Virtualization.framework's NAT starts at `192.168.64.0/24` but **`--method allowlist` (the default) is what a CI host wants.** It writes a subnet allowlist — the
picks the subnet at runtime and steps to the next free one when that range is already in use, so the one deterministic option: no prompt, no GUI session, and nothing to redo after a rebuild. It asks
same host can hand out `192.168.65.x` tomorrow. An allowlist naming only `192.168.64.0/24` then for your sudo password, reports which preferences file the write actually landed in, and then offers
looks configured while silently blocking every guest — the failure surfaces as `No route to host` to **reboot**, which is required: these values are read at boot, so restarting the service alone is
(errno 65) on the SSH connection, not as a permission error. The `/18` above spans not enough. Pass `--no-reboot` to defer that.
`192.168.64.0`–`192.168.127.255`, which covers the drift; if you would rather not think about
ranges at all, the RFC 1918 set `"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor`
reports `local network access` as a **pass** once it sees an allowlist that covers that span, and as
a **warning** when an allowlist exists but does not. Both keys are documented by Apple in
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
and are the workaround [Tart's FAQ](https://tart.run/faq/) recommends for the same problem.
**Why not just approve the prompt?** Because on this host there is usually nothing able to show it, 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
and when something does, it is attributed to the wrong program. set [Tart](https://tart.run/faq/) and orchard use. Narrow it with `--subnet`, repeatable, but **do
not pin it to a single /24**: Virtualization.framework's NAT starts at `192.168.64.0/24` but picks
the subnet at runtime and steps to the next free one when that range is already in use, so the same
host can hand out `192.168.65.x` tomorrow. An allowlist naming only `192.168.64.0/24` then looks
configured while silently blocking every guest — and the failure surfaces as `No route to host`
(errno 65) on the SSH connection, not as a permission error. `192.168.64.0/18` spans
`192.168.64.0`–`192.168.127.255`, which is the narrowest entry that covers the drift. `doctor`
reports `local network access` as a **pass** once it sees an allowlist covering that span, and as a
**warning** when an allowlist exists but does not. Both keys are documented by Apple in
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
**`--method prompt` takes effect immediately, with no reboot**, and is the better choice on a Mac
you are sitting in front of. It launches the installed `.app` through LaunchServices — which is what
makes the app its own responsible process — provokes the real system alert, and reports whether the
grant took. It needs `make install` to have run, a GUI session to show the alert in, and a Developer
ID signature for the grant to survive the next rebuild; `permissions status` reports that last one.
**Why the prompt needs that much machinery.** Left to itself, on this host there is usually nothing
able to show it, and when something does, it is attributed to the wrong program.
An app appears under **System Settings → Privacy & Security → Local Network** only *after* it has 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 attempted a connection to a guest, so an empty list on a fresh install is expected and means
@@ -490,14 +504,19 @@ miss:
approving it there does not carry over to the LaunchAgent. (If you are hunting for a row that 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".) seems missing, look for Terminal rather than for "Gitea macOS Runner".)
> **And under ad-hoc signing it is not durable anyway.** Local Network privacy does not use TCC; per `permissions grant --method prompt` exists to thread that needle: it starts the app through
> TN3179 it "uses your main executable UUID as part of its implementation", and the linker mints a LaunchServices rather than from the shell, so the app is its own responsible process and the
> fresh `LC_UUID` on essentially every rebuild. So `make install` after a code change presents as a decision is recorded against *its* identity — the same identity the LaunchAgent runs under.
> new app that must be approved again — and macOS offers no way to reset a Local Network decision
> back to undetermined, so stale entries accumulate. Signing with a Developer ID certificate fixes > **Under an ad-hoc signature it is not durable anyway.** Local Network privacy does not use TCC;
> the churn (see [Code signing](#code-signing)); the subnet allowlist above, keyed on the network > per TN3179 it "uses your main executable UUID as part of its implementation", and the linker mints
> rather than on the app, sidesteps the whole mechanism and is the recommendation for an unattended > a fresh `LC_UUID` on essentially every rebuild. So `make install` after a code change presents as
> machine either way. > a new app that must be approved again — and macOS offers no way to reset a Local Network decision
> back to undetermined, so stale entries accumulate. A Developer ID signature fixes the churn, since
> the identity is then anchored to the certificate rather than to the binary (see
> [Code signing](#code-signing)) — that is what makes `--method prompt` worth using at all. The
> subnet allowlist, keyed on the network rather than on the app, sidesteps the whole mechanism and
> remains the recommendation for an unattended machine.
--- ---
@@ -531,7 +550,7 @@ The checks, in order:
| `runner download url` | The `gitea-runner` release asset is reachable | | `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 | | `guest ssh` | Reachability of the most recent guest lease, when there is one. Warns on a timeout, which is most often a stale 24-hour lease for a guest that is already gone |
| `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) | | `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt. Fix either with `permissions grant` (§2.6) |
If the config file is missing or invalid, the host checks still run and the rest 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
+59 -46
View File
@@ -23,12 +23,12 @@ gitea-macos-runner service status
| --- | --- | --- | | --- | --- | --- |
| VM won't start; entitlement / `com.apple.security.virtualization` error | Running an unsigned binary, or one outside the signed `.app` bundle | `make sign` (or re-run `make install`); invoke the installed bundle, never `.build/release/…` | | 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, 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 | | 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) | | 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 | `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` | Widen it to `192.168.64.0/18` and reboot; `doctor` now warns about too-narrow allowlists | | 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>` |
@@ -105,26 +105,24 @@ prompt**: a LaunchAgent that was never granted permission (or was denied) cannot
An entry with a recent `lease` timestamp and the guest's MAC means networking is fine and the 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 \ gitea-macos-runner permissions status
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" gitea-macos-runner permissions grant # then reboot when it offers
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
``` ```
The `/18` is deliberate: the NAT subnet is chosen at runtime and slides to the next free /24 `grant` pre-authorizes the VM subnets and offers to reboot, which is required — the allowlist is
(192.168.65.x, .66.x, …) when one is taken, so a pinned `192.168.64.0/24` breaks the day it read at boot. It grants all of RFC 1918 by default; `--subnet` narrows it, but nothing narrower
moves. `doctor` reports `local network access` as a pass once it sees an allowlist covering that than `192.168.64.0/18` is safe, because the NAT subnet is chosen at runtime and slides to the next
span. This is the deterministic fix for an unattended host — free /24 (192.168.65.x, .66.x, …) when one is taken, so a pinned `192.168.64.0/24` breaks the day
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings) it moves. `doctor` reports `local network access` as a pass once it sees an allowlist covering
for why the interactive grant is not. that span.
Do not reach for the interactive grant instead. Booting a VM by hand from a Terminal makes the This is the deterministic fix for an unattended host. On a Mac with someone in front of it,
prompt (and the **System Settings → Privacy & Security → Local Network** row) belong to `permissions grant --method prompt` applies immediately with no reboot —
*Terminal* rather than to this app, and approving it there does not carry over to the see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
LaunchAgent. for what it does and why granting the prompt by hand does not work.
--- ---
@@ -156,23 +154,36 @@ gitea-macos-runner vm boot --image default
prompt and the Settings row belong to Terminal, and allowing it there does **not** carry over to the 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 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` 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 (errno 65) rather than as a permission error.
this interactively on an unattended host.
**Fix — deterministic, and what to use on any host running the LaunchAgent.** Allowlist the VM **Fix — deterministic, and what to use on any host running the LaunchAgent.** Allowlist the VM
subnet instead. It is keyed on the network rather than on the app, so no prompt is involved, and subnets instead. The allowlist is keyed on the network rather than on the app, so no prompt is
nothing needs redoing: 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/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
``` ```
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**.
**On a Mac you are sitting at,** `permissions grant --method prompt` is the alternative, and it
needs no reboot. It launches the installed `.app` through LaunchServices instead of exec'ing it from
the shell, which is exactly what makes the app its own responsible process — so the alert, and the
Settings row it creates, belong to the app rather than to Terminal, and the decision applies to the
LaunchAgent. It requires `make install` to have run, a GUI session, and a Developer ID signature to
be durable (see below); `permissions status` reports all three.
**If `--method prompt` reports "still blocked" and you never saw an alert,** macOS most likely
already has a decision on file for the app. It prompts exactly once per app identity and then
answers from that record forever — silently, with `EHOSTUNREACH`, and with no supported way to reset
it back to undetermined. The app *is* being evaluated under its own identity at that point (you can
confirm with `log show --last 2m --predicate 'subsystem == "com.apple.networkextension"'`, which
names the bundle ID on every attempt); the system simply is not asking. Switch the row on in
**System Settings → Privacy & Security → Local Network**, or use the allowlist, which bypasses the
per-app check entirely.
> **And an ad-hoc signed bundle cannot hold the grant anyway.** TN3179 notes that "local network > **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 > privacy uses your main executable UUID as part of its implementation". An ad-hoc signature has no
@@ -636,27 +647,29 @@ Two details make it look intermittent rather than like a permission problem:
responsible process, so the app's own identity is not what is being evaluated when you launch it 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. 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 subnets — the allowlist is keyed on the network, not on the app, so no
withdraw it and no prompt has to be answered: rebuild can withdraw it and no prompt has to be answered:
```sh ```sh
sudo defaults write com.apple.network.local-network \ gitea-macos-runner permissions grant
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
sudo reboot
``` ```
The values are only read at boot, so **the reboot is not optional** — until it happens, `defaults It writes both of Apple's keys with sudo, verifies the values read back, and offers to reboot. The
read com.apple.network.local-network` shows the new setting while the filter still behaves as values are only read at boot, so **the reboot is not optional** — until it happens, `defaults read
before. com.apple.network.local-network` shows the new setting while the filter still behaves as before.
Use `/18`, not `/24`. Virtualization.framework's NAT starts at `192.168.64.0/24` but chooses the The default grant is all of RFC 1918. If you narrow it with `--subnet`, use `/18`, not `/24`.
subnet at runtime and steps to the next free /24 when that one is in use, so hosts drift to Virtualization.framework's NAT starts at `192.168.64.0/24` but chooses the subnet at runtime and
`192.168.65.x` and beyond. An allowlist naming a single /24 that the NAT has since moved off is the steps to the next free /24 when that one is in use, so hosts drift to `192.168.65.x` and beyond. An
worst case: it reads as configured, `doctor` used to call it a pass, and every guest connection allowlist naming a single /24 that the NAT has since moved off is the worst case: it reads as
still fails with errno 65. `doctor` now warns instead when the allowlist does not cover configured, `doctor` used to call it a pass, and every guest connection still fails with errno 65.
`192.168.64.0`–`192.168.127.255`. `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 **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 pass naming the range. Re-run the command that failed; nothing else needs redoing, and