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

This commit is contained in:
2026-08-07 15:41:01 -07:00
parent 6b0c01b74b
commit 902bea5091
11 changed files with 450 additions and 91 deletions
+132 -24
View File
@@ -78,25 +78,29 @@ public enum Doctor {
/// 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. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests
/// 5. **Code identity is stable**, i.e. the bundle is signed with a real
/// team-anchored certificate rather than ad-hoc. Warns on ad-hoc,
/// because that is what makes Local Network grants evaporate on every
/// rebuild (check 12).
/// 6. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests
/// write.
/// 6. **`login.keychain` unlocked**, via `security show-keychain-info
/// 7. **`login.keychain` unlocked**, via `security show-keychain-info
/// login.keychain`. macOS 15+ refuses to start a VM otherwise — the
/// reason the daemon must be a LaunchAgent in a logged-in session.
/// 7. **Gitea reachable and the token has admin scope**, probed with
/// 8. **Gitea reachable and the token has admin scope**, probed with
/// ``GiteaClient/listRunners()``. A non-admin token fails here rather
/// than at the first poll.
/// 8. **Registration token resolvable** from file, inline value, or (if
/// 9. **Registration token resolvable** from file, inline value, or (if
/// enabled) the API.
/// 9. **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.
/// 10. **Guest SSH**, against whichever slot currently holds a DHCP lease —
/// 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.
/// 11. **Local Network privacy**. Passes when a subnet allowlist is set in
/// 12. **Local Network privacy**. Passes when a subnet allowlist is set in
/// `com.apple.network.local-network`; otherwise informational. On
/// macOS 15+ the first attempt to reach a guest over the NAT link can
/// be blocked by the Local Network permission prompt, which a
@@ -160,12 +164,13 @@ public enum Doctor {
}
/// The configuration-independent host checks: architecture, OS version,
/// framework support, entitlement.
/// framework support, entitlement, code identity.
public static func hostChecks() -> [DoctorCheck] {
[
checkHostCapability(),
checkVirtualizationSupported(),
checkVirtualizationEntitlement(),
checkCodeSignature(),
]
}
@@ -193,11 +198,7 @@ public enum Doctor {
// 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 inAppBundle = executable.contains(".app/Contents/MacOS/")
let signedTarget = inAppBundle
? String(executable.prefix(upTo: executable.range(of: ".app/Contents/MacOS/")!.upperBound)
.dropLast("/Contents/MacOS/".count))
: executable
let (signedTarget, inAppBundle) = signableTarget(for: executable)
let result = DoctorShell.run(
"/usr/bin/codesign",
@@ -232,6 +233,112 @@ public enum Doctor {
)
}
/// Whether the bundle's code identity is stable across rebuilds.
///
/// This is not a cosmetic "is it properly signed" check — it is the root
/// cause of the project's most confusing failure. A Developer ID signature
/// carries a designated requirement anchored to the team
/// (`certificate leaf[subject.OU] = "…"`), so macOS recognises every later
/// build as the same program and the app's Local Network grant persists. An
/// **ad-hoc** signature has no such anchor, so per
/// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
/// the system identifies the app by its main executable's Mach-O UUID —
/// which the linker regenerates on essentially every link. Each
/// `make install` therefore presents a program macOS has never seen, its
/// permission reverts to undetermined, and guest SSH starts failing with
/// `No route to host` minutes after a build that worked.
///
/// Ad-hoc is a `warn`, not a `fail`: everything still runs, and it is the
/// only option on a host without a certificate (CI signs this way
/// deliberately). It just needs the subnet allowlist to compensate.
///
/// - Parameter binaryPath: Defaults to the current executable.
/// - Returns: The check result.
public static func checkCodeSignature(
binaryPath: String = CommandLine.arguments.first ?? ""
) -> DoctorCheck {
let name = "code identity"
guard let executable = resolveExecutablePath(binaryPath) else {
return DoctorCheck(
name: name,
result: .warn,
detail: "could not locate the running executable to inspect",
remediation: "build and install the signed bundle: `make install`"
)
}
let (target, inAppBundle) = signableTarget(for: executable)
let result = DoctorShell.run("/usr/bin/codesign", ["-dv", target])
guard result.exitCode == 0 else {
return DoctorCheck(
name: name,
result: inAppBundle ? .fail : .warn,
detail: "\(target) carries no code signature",
remediation: "sign the bundle: `make sign` (or `make install`)"
)
}
let team = value(of: "TeamIdentifier", in: result.output)
let identifier = value(of: "Identifier", in: result.output) ?? "?"
let hardened = result.output.contains("flags=") && result.output.contains("runtime")
guard let team, team != "not set" else {
return DoctorCheck(
name: name,
result: .warn,
detail: "\(identifier) is ad-hoc signed (no team identifier)",
remediation: """
An ad-hoc signature has no stable designated requirement, so macOS falls back \
to identifying this app by its Mach-O UUID — regenerated on every build. Any \
Local Network grant is withdrawn by the next `make install`, and guests then \
fail with "No route to host". Sign with a Developer ID certificate \
(`make sign TEAM_ID=<team>`), or set the subnet allowlist so no grant is \
needed at all — see the "local network access" check.
"""
)
}
return DoctorCheck(
name: name,
result: .pass,
detail: "\(identifier), team \(team)"
+ (hardened ? ", hardened runtime" : "")
)
}
/// Reads a `Key=value` line out of `codesign -dv` output.
///
/// `codesign` writes this block to stderr, one `Key=value` per line, and
/// repeats some keys (`Authority`); the first match is the one that matters.
private static func value(of key: String, in output: String) -> String? {
for line in output.split(separator: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("\(key)=") else { continue }
return String(trimmed.dropFirst(key.count + 1))
}
return nil
}
/// The artifact `codesign` should be pointed at: the enclosing `.app` when
/// the executable lives inside one, otherwise the executable itself.
///
/// Signatures and entitlements are sealed on the bundle, so querying the
/// bare Mach-O inside it — or one copied out of it — answers the wrong
/// question.
///
/// - Parameter executable: An absolute, symlink-resolved executable path.
/// - Returns: The path to query, and whether it is an `.app` bundle.
private static func signableTarget(for executable: String) -> (path: String, inAppBundle: Bool) {
guard let marker = executable.range(of: ".app/Contents/MacOS/") else {
return (executable, false)
}
let bundle = executable.prefix(upTo: marker.upperBound)
.dropLast("/Contents/MacOS/".count)
return (String(bundle), true)
}
/// Whether `login.keychain` is currently unlocked.
public static func checkLoginKeychain() -> DoctorCheck {
let name = "login.keychain unlocked"
@@ -696,14 +803,15 @@ public enum Doctor {
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, which a background LaunchAgent cannot answer. The app cannot be \
pre-approved: it only appears under System Settings → Privacy & Security → Local \
Network once it has actually attempted a guest connection. To trigger and answer \
the prompt by hand, run `gitea-macos-runner vm boot --image default` once from a \
Terminal in the GUI session. On an unattended CI host prefer the subnet \
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \
"192.168.64.0/18" (then reboot). See docs/setup.md §2.6.
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. Prefer the subnet \
allowlist: it needs no prompt, covers every process, and survives rebuilds. \
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
"""
)
}
+57 -2
View File
@@ -47,10 +47,25 @@ public struct ServiceStatus: Sendable, Equatable {
/// because this is the failure people hit first.
public enum LaunchdService {
/// The `launchd` label, matching `CFBundleIdentifier`.
public static let label = "xyz.blakeslee.gitea-macos-runner"
public static let label = "xyz.blakeslee.gitea-macos-vm-orchestrator"
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist`.
/// Labels this service used to install under.
///
/// Renaming the label renames the plist, so an upgrade that only wrote the
/// new one would leave the old job bootstrapped and still running the old
/// binary — two daemons polling the same Gitea instance, racing to claim
/// the same queued jobs, with no hint in the logs that a second one exists.
/// ``install(executablePath:configPath:)`` and ``uninstall()`` therefore
/// evict these first. Append, never edit, when the label changes again.
public static let legacyLabels = ["xyz.blakeslee.gitea-macos-runner"]
/// `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist`.
public static var agentPlistURL: URL {
agentPlistURL(for: label)
}
/// The LaunchAgent plist path for an arbitrary label.
public static func agentPlistURL(for label: String) -> URL {
URL(fileURLWithPath: RunnerConfig.expandTilde("~/Library/LaunchAgents/\(label).plist"))
}
@@ -106,6 +121,11 @@ public enum LaunchdService {
withIntermediateDirectories: true
)
// Upgrading from a build that installed under an older label: evict it
// before bootstrapping this one, or both run at once. See
// ``legacyLabels``.
removeLegacyAgents()
// A reinstall over a loaded job is the common case (upgrade, config
// change), so unload before rewriting rather than failing on "already
// bootstrapped".
@@ -140,13 +160,48 @@ public enum LaunchdService {
}
/// Unloads the job and removes the plist. Safe when not installed.
///
/// Also evicts any ``legacyLabels`` job, so `service uninstall` leaves
/// nothing of this project loaded regardless of which version installed it.
public static func uninstall() throws {
removeLegacyAgents()
_ = try? uninstallJobOnly()
if FileManager.default.fileExists(atPath: agentPlistURL.path) {
try FileManager.default.removeItem(at: agentPlistURL)
}
}
/// Boots out and deletes any LaunchAgent installed under a ``legacyLabels``
/// entry.
///
/// Best effort by design: a legacy job that was never installed, is not
/// loaded, or whose plist is already gone is not an error, and failing to
/// evict one must not block installing the current job.
///
/// - Returns: The legacy labels that were actually found and removed, for
/// callers that want to tell the operator a migration happened.
@discardableResult
public static func removeLegacyAgents() -> [String] {
var removed: [String] = []
for legacy in legacyLabels {
let plist = agentPlistURL(for: legacy)
let bootout = LaunchdShell.run(
"/bin/launchctl", ["bootout", "\(domainTarget)/\(legacy)"])
if bootout.exitCode != 0 {
_ = LaunchdShell.run("/bin/launchctl", ["unload", "-w", plist.path])
}
if FileManager.default.fileExists(atPath: plist.path) {
try? FileManager.default.removeItem(at: plist)
removed.append(legacy)
} else if bootout.exitCode == 0 {
// Loaded, but from a plist that is no longer on disk.
removed.append(legacy)
}
}
return removed
}
/// Unloads the job but leaves the plist on disk.
private static func uninstallJobOnly() throws {
let bootout = LaunchdShell.run("/bin/launchctl", ["bootout", serviceTarget])
@@ -133,7 +133,7 @@ enum VZAppRuntime {
/// Signals land here rather than on `.main`. See ``run(onSignal:body:)``.
private static let signalQueue = DispatchQueue(
label: "xyz.blakeslee.gitea-macos-runner.signals")
label: "xyz.blakeslee.gitea-macos-vm-orchestrator.signals")
/// Starts the run loop and runs `body` alongside it. Never returns.
///
@@ -21,7 +21,7 @@ struct ServiceCommand: AsyncParsableCommand {
struct Install: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "install",
abstract: "Write ~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-runner.plist and load it.",
abstract: "Write ~/Library/LaunchAgents/\(LaunchdService.label).plist and load it.",
discussion: """
Points the agent at the installed, signed .app bundle — not at a bare \
binary. The com.apple.security.virtualization entitlement only survives \
@@ -46,6 +46,13 @@ struct ServiceCommand: AsyncParsableCommand {
CLI.note("warning: \(RunnerConfig.expandTilde(options.configPath)) is missing or invalid; the agent will fail to start until it is fixed")
}
// Done before install (which also does it) purely so the operator
// is told: an agent silently vanishing from launchctl is alarming
// if you do not know a rename happened.
for legacy in LaunchdService.removeLegacyAgents() {
CLI.note("removed legacy agent \(legacy) (renamed to \(LaunchdService.label))")
}
try LaunchdService.install(executablePath: executablePath, configPath: configPath)
print("installed \(LaunchdService.agentPlistURL.path)")
@@ -68,7 +75,11 @@ struct ServiceCommand: AsyncParsableCommand {
func run() async throws {
let path = LaunchdService.agentPlistURL.path
let existed = FileManager.default.fileExists(atPath: path)
let legacy = LaunchdService.removeLegacyAgents()
try LaunchdService.uninstall()
for label in legacy {
print("removed legacy agent \(label)")
}
print(existed ? "removed \(path)" : "not installed (\(path))")
}
}