Files
gitea-macos-vm-orchestrator/Sources/RunnerHost/Doctor.swift
T

901 lines
38 KiB
Swift

import Foundation
import RunnerCore
import Virtualization
/// The outcome of one preflight check.
public struct DoctorCheck: Sendable, Equatable {
/// How a check turned out.
public enum Result: Sendable, Equatable {
/// Requirement satisfied.
case pass
/// Requirement not satisfied; the daemon will not work.
case fail
/// Not a hard requirement, but worth knowing about.
case warn
/// Informational only.
case info
}
/// Short check name, e.g. `virtualization entitlement`.
public let name: String
/// Outcome.
public let result: Result
/// What was observed.
public let detail: String
/// What to do about it, when the outcome is not ``Result/pass``.
public let remediation: String?
public init(name: String, result: Result, detail: String, remediation: String? = nil) {
self.name = name
self.result = result
self.detail = detail
self.remediation = remediation
}
/// Whether this check blocks the daemon from working.
public var isBlocking: Bool { result == .fail }
}
extension DoctorCheck.Result {
/// Lowercase name, for JSON output and log lines.
public var label: String {
switch self {
case .pass: return "pass"
case .fail: return "fail"
case .warn: return "warn"
case .info: return "info"
}
}
/// Single-character marker used by ``Doctor/format(_:)``.
public var symbol: String {
switch self {
case .pass: return "✓"
case .fail: return "✗"
case .warn: return "!"
case .info: return "·"
}
}
}
/// Preflight checks for a host that is supposed to run macOS guests.
///
/// Each of these corresponds to a failure mode that is otherwise diagnosed only
/// by a confusing runtime error deep inside the boot path, so `doctor` exists to
/// surface them all at once, before anything is installed.
public enum Doctor {
/// Runs every check.
///
/// Checks performed:
///
/// 1. **Architecture is arm64.** Virtualization cannot run macOS guests on
/// Intel at all.
/// 2. **Host macOS ≥ 26.** Required for ASIF disks; the guest-provisioning
/// automation additionally wants 27.
/// 3. **`VZVirtualMachine.isSupported`.** The framework's own verdict.
/// 4. **`com.apple.security.virtualization` entitlement present** on the
/// running binary, read with `codesign -d --entitlements - <path>`.
/// Running from `.build/` instead of the signed `.app` is the single most
/// common setup mistake, and this is what catches it.
/// 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.
/// 7. **`login.keychain` unlocked**, via `security show-keychain-info
/// login.keychain`. macOS 15+ refuses to start a VM otherwise — the
/// reason the daemon must be a LaunchAgent in a logged-in session.
/// 8. **Gitea reachable and the token has admin scope**, probed with
/// ``GiteaClient/listRunners()``. A non-admin token fails here rather
/// than at the first poll.
/// 9. **Registration token resolvable** from file, inline value, or (if
/// enabled) the API.
/// 10. **Runner download URL is live**, via a one-byte ranged `GET` — the
/// same verb the real download uses, because the presigned redirect
/// target is signed per method. Catches a version bump that no longer
/// has a darwin-arm64 asset.
/// 11. **Guest SSH**, against whichever slot currently holds a DHCP lease —
/// the one check that exercises host → vmnet → guest `sshd` → password
/// auth end to end. Informational when no guest is up, since `doctor`
/// will not boot one.
/// 12. **Local Network privacy**. Passes when a subnet allowlist is set in
/// `com.apple.network.local-network`; otherwise informational. On
/// macOS 15+ the first attempt to reach a guest over the NAT link can
/// be blocked by the Local Network permission prompt, which a
/// background agent cannot answer.
///
/// - Parameter config: Validated configuration. Gitea-dependent checks are
/// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set.
/// - Returns: Checks in the order above.
public static func runChecks(config: RunnerConfig) async -> [DoctorCheck] {
var checks = hostChecks()
checks.append(checkDiskSpace(config: config))
checks.append(checkLoginKeychain())
checks.append(contentsOf: await checkGitea(config: config))
checks.append(await checkRunnerDownloadURL(config: config))
checks.append(await checkGuestSSH(config: config))
checks.append(localNetworkNote())
return checks
}
/// Runs every check, loading configuration from `path` first.
///
/// The host checks still run when the configuration is missing or invalid,
/// which is the state a first-time operator is actually in.
///
/// - Parameter configPath: Path to the configuration file; tilde-expanded.
/// - Returns: Checks, with configuration loading itself reported as a check.
public static func runChecks(configPath: String) async -> [DoctorCheck] {
var checks = hostChecks()
let loaded: RunnerConfig
do {
loaded = try RunnerConfig.load(from: configPath).validated()
checks.append(
DoctorCheck(
name: "configuration",
result: .pass,
detail: "loaded and validated \(RunnerConfig.expandTilde(configPath))"
)
)
} catch {
checks.append(
DoctorCheck(
name: "configuration",
result: .fail,
detail: "\(error)",
remediation: "run `gitea-macos-runner config init` and edit \(RunnerConfig.expandTilde(configPath))"
)
)
checks.append(localNetworkNote())
return checks
}
checks.append(checkDiskSpace(config: loaded))
checks.append(checkLoginKeychain())
checks.append(contentsOf: await checkGitea(config: loaded))
checks.append(await checkRunnerDownloadURL(config: loaded))
checks.append(await checkGuestSSH(config: loaded))
checks.append(contentsOf: checkTokenFilePermissions(config: loaded))
checks.append(localNetworkNote())
return checks
}
/// The configuration-independent host checks: architecture, OS version,
/// framework support, entitlement, code identity.
public static func hostChecks() -> [DoctorCheck] {
[
checkHostCapability(),
checkVirtualizationSupported(),
checkVirtualizationEntitlement(),
checkCodeSignature(),
]
}
/// Whether the running binary carries `com.apple.security.virtualization`.
///
/// - Parameter binaryPath: Defaults to the current executable.
/// - Returns: The check result.
public static func checkVirtualizationEntitlement(
binaryPath: String = CommandLine.arguments.first ?? ""
) -> DoctorCheck {
let name = "virtualization entitlement"
let remediation = """
build and install the signed bundle: `make install`, then run \
~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner
"""
guard let executable = resolveExecutablePath(binaryPath) else {
return DoctorCheck(
name: name,
result: .warn,
detail: "could not locate the running executable to inspect",
remediation: remediation
)
}
// 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.
let (signedTarget, inAppBundle) = signableTarget(for: executable)
let result = DoctorShell.run(
"/usr/bin/codesign",
["-d", "--entitlements", "-", "--xml", signedTarget]
)
let hasEntitlement = result.output.contains("com.apple.security.virtualization")
if hasEntitlement {
return DoctorCheck(
name: name,
result: .pass,
detail: "present on \(signedTarget)"
)
}
if inAppBundle {
return DoctorCheck(
name: name,
result: .fail,
detail: "\(signedTarget) is not signed with com.apple.security.virtualization",
remediation: "re-sign the bundle: `make sign` (or `make install`)"
)
}
// Running the plain SwiftPM binary is normal for `doctor`, `config`, and
// `service`; it only becomes fatal when a VM is actually started.
return DoctorCheck(
name: name,
result: .warn,
detail: "running an unsigned binary at \(executable); VM starts will fail",
remediation: remediation
)
}
/// 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.
public static func checkLoginKeychain() -> DoctorCheck {
let name = "login.keychain unlocked"
let result = DoctorShell.run("/usr/bin/security", ["show-keychain-info", "login.keychain"])
if result.exitCode == 0 {
return DoctorCheck(name: name, result: .pass, detail: "unlocked")
}
return DoctorCheck(
name: name,
result: .warn,
detail: "locked or unavailable (security exited \(result.exitCode))",
remediation: """
macOS 15+ refuses to start a VM while login.keychain is locked. Configure \
automatic login, and do not lock the session — the daemon runs as a \
LaunchAgent inside it.
"""
)
}
/// Whether the host architecture and macOS version can run macOS guests.
public static func checkHostCapability() -> DoctorCheck {
let name = "host capability"
let version = ProcessInfo.processInfo.operatingSystemVersion
let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
// Emitted straight from the preprocessor branch rather than via a `let
// isAppleSilicon` flag: with the flag, one arm is a compile-time
// constant and the compiler warns that the other is dead code.
#if !arch(arm64)
return DoctorCheck(
name: name,
result: .fail,
detail: "not an Apple silicon host; macOS guests require arm64",
remediation: "run this daemon on an Apple silicon Mac"
)
#else
guard version.majorVersion >= 26 else {
return DoctorCheck(
name: name,
result: .fail,
detail: "macOS \(versionString); this daemon requires macOS 26 or newer",
remediation: "upgrade the host: ASIF sparse disks need macOS 26+"
)
}
if version.majorVersion < 27 {
return DoctorCheck(
name: name,
result: .warn,
detail: "arm64, macOS \(versionString)",
remediation: """
automated guest provisioning (VZMacGuestProvisioningOptions) needs macOS 27 \
on both host and guest; on 26 the first boot's Setup Assistant must be \
completed by hand once per image
"""
)
}
return DoctorCheck(name: name, result: .pass, detail: "arm64, macOS \(versionString)")
#endif
}
/// The framework's own verdict on this host.
public static func checkVirtualizationSupported() -> DoctorCheck {
let name = "Virtualization.framework"
if VZVirtualMachine.isSupported {
return DoctorCheck(name: name, result: .pass, detail: "VZVirtualMachine.isSupported == true")
}
return DoctorCheck(
name: name,
result: .fail,
detail: "VZVirtualMachine.isSupported == false",
remediation: "this host cannot run virtual machines"
)
}
/// Free space on the store volume against `storage.minFreeDiskGB`.
public static func checkDiskSpace(config: RunnerConfig) -> DoctorCheck {
let name = "free disk space"
let store = VMStore(config: config)
do {
let free = try store.freeDiskSpace()
let freeGB = Double(free) / 1_073_741_824
let detail = String(
format: "%.1f GB free at %@ (minimum %d GB)",
freeGB, config.storeDirectoryURL.path, config.storage.minFreeDiskGB
)
if freeGB < Double(config.storage.minFreeDiskGB) {
return DoctorCheck(
name: name,
result: .fail,
detail: detail,
remediation: "free space, or lower storage.minFreeDiskGB"
)
}
return DoctorCheck(name: name, result: .pass, detail: detail)
} catch {
return DoctorCheck(
name: name,
result: .warn,
detail: "could not measure free space: \(error)",
remediation: "check that \(config.storeDirectoryURL.path) exists and is readable"
)
}
}
/// Reachability, admin scope, and registration-token availability.
public static func checkGitea(config: RunnerConfig) async -> [DoctorCheck] {
var checks: [DoctorCheck] = []
let adminToken: String?
do {
adminToken = try config.resolveAdminToken()
} catch {
checks.append(
DoctorCheck(
name: "gitea admin token",
result: .fail,
detail: "\(error)",
remediation: "check gitea.adminTokenFile / gitea.adminToken"
)
)
return checks
}
guard let adminToken, !adminToken.isEmpty else {
checks.append(
DoctorCheck(
name: "gitea admin api",
result: .warn,
detail: "no admin token configured; skipped",
remediation: "set gitea.adminTokenFile to a file holding an ADMIN user's API token"
)
)
return checks
}
let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken)
do {
let runners = try await client.listRunners()
checks.append(
DoctorCheck(
name: "gitea admin api",
result: .pass,
detail: "\(config.gitea.instanceURL.absoluteString) reachable; \(runners.count) runner(s) registered"
)
)
} catch {
checks.append(
DoctorCheck(
name: "gitea admin api",
result: .fail,
detail: "\(error)",
remediation: """
every endpoint used lives under /api/v1/admin/actions/ — the token must \
belong to a Gitea administrator, and the instance must be reachable
"""
)
)
}
checks.append(await checkRegistrationToken(config: config, client: client))
return checks
}
/// Whether a registration token can be obtained at all.
private static func checkRegistrationToken(config: RunnerConfig, client: GiteaClient) async -> DoctorCheck {
let name = "registration token"
do {
if let staticToken = try config.resolveStaticRegistrationToken(), !staticToken.isEmpty {
return DoctorCheck(name: name, result: .pass, detail: "resolved from configuration")
}
} catch {
return DoctorCheck(
name: name,
result: .fail,
detail: "\(error)",
remediation: "check gitea.registrationTokenFile"
)
}
guard config.gitea.fetchRegistrationTokenViaAPI else {
return DoctorCheck(
name: name,
result: .fail,
detail: "no static token configured and gitea.fetchRegistrationTokenViaAPI is off",
remediation: """
seed a fixed token server-side (GITEA_RUNNER_REGISTRATION_TOKEN) and point \
gitea.registrationTokenFile at a copy of it
"""
)
}
do {
let token = try await client.getRegistrationToken()
guard !token.isEmpty else {
return DoctorCheck(
name: name,
result: .fail,
detail: "the API returned an empty token",
remediation: "configure gitea.registrationTokenFile instead"
)
}
return DoctorCheck(name: name, result: .pass, detail: "fetched from the admin API")
} catch {
return DoctorCheck(
name: name,
result: .fail,
detail: "\(error)",
remediation: "configure gitea.registrationTokenFile instead"
)
}
}
/// Whether the configured `gitea-runner` asset still exists.
public static func checkRunnerDownloadURL(config: RunnerConfig) async -> DoctorCheck {
let name = "runner download url"
let url: URL
do {
url = try config.runner.resolvedDownloadURL
} catch {
return DoctorCheck(
name: name,
result: .fail,
detail: "\(error)",
remediation: "check runner.runnerDownloadURL and runner.version"
)
}
// A ranged GET, not a HEAD. gitea.com answers an asset request with a
// 303 to a presigned object-storage URL, and the signature covers the
// *method of the request that minted it*: ask with HEAD and you get a
// HEAD-signed URL. URLSession then follows the 303 and — per RFC 7231
// §6.4.4 — rewrites the method to GET, so the signed URL is replayed
// with the one verb it was not signed for and the store answers 403
// SignatureDoesNotMatch. Probing with the same verb the real download
// uses is the only way to make the answer mean anything. `bytes=0-0`
// keeps it to one byte instead of the whole 20-plus MB asset.
do {
let status = try await probeStatus(url: url, method: "GET", range: "bytes=0-0")
if status <= 399 {
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(status)")
}
// A host that rejects ranges outright still deserves a second look
// before we call the asset missing.
let fallback = try await probeStatus(url: url, method: "HEAD", range: nil)
if fallback <= 399 {
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(fallback)")
}
return DoctorCheck(
name: name,
result: .warn,
detail: "\(url.absoluteString) → \(status)",
remediation: "check runner.version and runner.runnerDownloadURL for a darwin-arm64 asset"
)
} catch {
// Best effort: a proxy or offline build host is not a reason to
// block the daemon.
return DoctorCheck(
name: name,
result: .warn,
detail: "could not reach \(url.absoluteString): \(error.localizedDescription)",
remediation: nil
)
}
}
/// Issues one probe request and reports its status code, or 0 if the
/// response was not HTTP.
private static func probeStatus(url: URL, method: String, range: String?) async throws -> Int {
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 15
if let range {
request.setValue(range, forHTTPHeaderField: "Range")
}
let (_, response) = try await URLSession.shared.data(for: request)
return (response as? HTTPURLResponse)?.statusCode ?? 0
}
/// Warns about token files readable by other users on this Mac.
public static func checkTokenFilePermissions(config: RunnerConfig) -> [DoctorCheck] {
let insecure = config.insecureTokenFilePaths
guard !insecure.isEmpty else { return [] }
return [
DoctorCheck(
name: "token file permissions",
result: .warn,
detail: "group/world readable: \(insecure.joined(separator: ", "))",
remediation: "chmod 600 \(insecure.joined(separator: " "))"
)
]
}
/// Whether a guest that is up right now actually accepts an SSH session.
///
/// Every other check in this file inspects the host. This one exercises the
/// exact path the boot sequence depends on and that nothing else proves:
/// host → vmnet → guest `sshd` → password auth with `guest.username` /
/// `guest.password`. It is the difference between "the daemon never got a
/// runner online" and a named cause — wrong credentials, Local Network
/// privacy blocking the link, or a guest image whose Remote Login is off.
///
/// Read-only with respect to host state: it uses the MACs already persisted
/// in `state.json` and never generates them, so running `doctor` on a fresh
/// host does not quietly create the slot address table.
///
/// - Note: Informational when no guest is currently leased. `doctor` must not
/// boot a VM — that costs minutes and a slot out of the host's hard cap of
/// two — so with nothing running there is simply nothing to probe. To make
/// this check meaningful, leave a guest up (`gitea-macos-runner vm boot`)
/// and run `doctor` again.
public static func checkGuestSSH(config: RunnerConfig) async -> DoctorCheck {
let name = "guest ssh"
let macs: [String]
do {
macs = try VMStore(config: config).loadState().slotMACAddresses
} catch {
return DoctorCheck(
name: name,
result: .warn,
detail: "could not read host state: \(error)",
remediation: "check that \(config.storeDirectoryURL.path) is readable"
)
}
guard !macs.isEmpty else {
return DoctorCheck(
name: name,
result: .info,
detail: "no slot MAC addresses assigned yet; skipped",
remediation: nil
)
}
// Newest lease wins if a slot somehow holds more than one: that is the
// guest currently on the link.
let leases = DHCPLeaseParser.parseFile()
guard let (mac, lease) = macs.lazy
.compactMap({ mac in DHCPLeaseParser.lease(forMAC: mac, in: leases).map { (mac, $0) } })
.first
else {
return DoctorCheck(
name: name,
result: .info,
detail: "no guest currently holds a DHCP lease; skipped",
remediation: """
this check only runs against a guest that is already up. To exercise the \
host→guest SSH path, run `gitea-macos-runner vm boot --image default` and \
then `doctor` again.
"""
)
}
// Short and fixed rather than derived from `scheduler.bootTimeoutSeconds`:
// this probes a guest that is already booted, so a slow answer is a
// finding, not something to wait fifteen minutes for.
do {
try await waitForSSH(
host: lease.ipAddress,
username: config.guest.username,
password: config.guest.password,
timeout: .seconds(20),
pollInterval: .seconds(2)
)
return DoctorCheck(
name: name,
result: .pass,
detail: "authenticated to \(config.guest.username)@\(lease.ipAddress) (\(mac))"
)
} catch let error as CoreError {
let detail = "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)"
switch error {
case .timeout:
// Nothing answered. bootpd leases last 24 hours and the slot MACs
// are persistent, so on any host that has ever run the daemon the
// most likely explanation is a lease outliving the guest that held
// it — not a broken host. Calling that `.fail` would make `doctor`
// cry wolf on a perfectly healthy idle machine.
return DoctorCheck(
name: name,
result: .warn,
detail: detail,
remediation: """
most likely a stale lease: bootpd keeps leases for 24 hours, so this \
address may belong to a guest that has already been torn down. If a \
guest really is up at this address, the daemon cannot reach it either — \
check that the host's Local Network permission is not dropping the \
connection (see the "local network access" check).
"""
)
default:
// Authentication reached the guest and was refused: the guest is up
// and the credentials are wrong. Nothing about that improves on its
// own, and every boot will fail the same way.
return DoctorCheck(
name: name,
result: .fail,
detail: detail,
remediation: """
the daemon authenticates over this exact path, so no boot can succeed \
while it fails. Check that guest.username and guest.password match an \
account in the guest image, and that Remote Login is enabled there.
"""
)
}
} catch {
return DoctorCheck(
name: name,
result: .warn,
detail: "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)",
remediation: nil
)
}
}
/// The macOS 15+ Local Network permission note.
///
/// Reports `.pass` when the host carries a subnet allowlist that actually
/// covers where guests turn up, because that bypasses the prompt entirely.
/// An allowlist that names some *other* subnet is worse than none, since it
/// looks configured while blocking every guest, so it warns rather than
/// passing. Without one this stays informational: we cannot see the grant
/// itself, since Local Network privacy is a Network Extension packet filter
/// rather than a TCC entry, so there is no database to query and `tccutil`
/// does not apply (Apple, TN3179).
public static func localNetworkNote() -> DoctorCheck {
let name = "local network access"
let status = LocalNetworkPolicy.status()
if status.isConfigured {
if status.coversGuestRange {
return DoctorCheck(
name: name,
result: .pass,
detail: "subnet allowlist set: \(status.allowlist.joined(separator: ", "))"
)
}
return DoctorCheck(
name: name,
result: .warn,
detail: "subnet allowlist set but does not cover the guest range: "
+ status.allowlist.joined(separator: ", "),
remediation: """
Virtualization.framework's NAT does not stay on 192.168.64.0/24 — it moves \
to the next free /24 (192.168.65.x, .66.x, …) when one is taken, so \
an allowlist pinned to a single /24 stops working the day the subnet shifts \
and every guest connection then fails with "No route to host". Widen it: \
`gitea-macos-runner permissions grant`, then reboot. See docs/setup.md §2.6.
"""
)
}
return DoctorCheck(
name: name,
result: .info,
detail: "guests are reached over the host-private NAT link",
remediation: """
on macOS 15+ the first connection to a guest can be blocked by the Local Network \
privacy prompt, and frequently there is nothing able to answer it. A LaunchAgent \
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 & \
Security → Local Network row belong to Terminal rather than to this app — and \
granting it to Terminal does not carry over to the LaunchAgent. Grant it with \
`gitea-macos-runner permissions grant`, which writes a subnet allowlist that \
needs no prompt, covers every process, and survives rebuilds — then reboot. \
`permissions status` explains both routes. See docs/setup.md §2.6.
"""
)
}
/// Renders checks as aligned, human-readable lines for the CLI.
public static func format(_ checks: [DoctorCheck]) -> String {
let width = checks.map(\.name.count).max() ?? 0
var lines: [String] = []
for check in checks {
let padded = check.name.padding(toLength: max(width, check.name.count), withPad: " ", startingAt: 0)
lines.append("\(check.result.symbol) \(padded) \(check.detail)")
if check.result != .pass, let remediation = check.remediation {
for (index, wrapped) in wrap(remediation, width: 76).enumerated() {
let prefix = index == 0 ? "→ " : " "
lines.append(String(repeating: " ", count: width + 4) + prefix + wrapped)
}
}
}
let failures = checks.filter(\.isBlocking).count
let warnings = checks.filter { $0.result == .warn }.count
lines.append("")
lines.append("\(checks.count) checks, \(failures) failed, \(warnings) warned")
return lines.joined(separator: "\n")
}
/// Greedy word wrap for remediation text.
private static func wrap(_ text: String, width: Int) -> [String] {
var lines: [String] = []
var current = ""
for word in text.split(whereSeparator: { $0 == " " || $0 == "\n" }) {
if current.isEmpty {
current = String(word)
} else if current.count + 1 + word.count <= width {
current += " " + word
} else {
lines.append(current)
current = String(word)
}
}
if !current.isEmpty { lines.append(current) }
return lines
}
/// Resolves the running executable, preferring the bundle's own record of it
/// over `argv[0]`, which may be a symlink or a bare command name.
private static func resolveExecutablePath(_ candidate: String) -> String? {
let fm = FileManager.default
if !candidate.isEmpty, candidate.hasPrefix("/"), fm.fileExists(atPath: candidate) {
return URL(fileURLWithPath: candidate).resolvingSymlinksInPath().path
}
if let executableURL = Bundle.main.executableURL {
return executableURL.resolvingSymlinksInPath().path
}
return nil
}
}
/// Minimal synchronous process runner for the tools `doctor` shells out to.
private enum DoctorShell {
struct Output {
let exitCode: Int32
let output: String
}
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
// codesign and security both report on stderr; merge so callers can grep
// one stream.
process.standardError = pipe
do {
try process.run()
} catch {
return Output(exitCode: 127, output: "\(error)")
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return Output(exitCode: process.terminationStatus, output: String(data: data, encoding: .utf8) ?? "")
}
}