refactor(macos): move launchctl + plist snapshot

This commit is contained in:
Peter Steinberger
2026-01-07 20:13:21 +00:00
parent 54960d1380
commit d45fcc44da
4 changed files with 161 additions and 129 deletions

View File

@@ -43,15 +43,15 @@ enum GatewayLaunchAgentManager {
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
} }
static func status() async -> Bool { static func isLoaded() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0 return result.status == 0
} }
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled { if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"]) _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL) try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else { guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
@@ -60,23 +60,31 @@ enum GatewayLaunchAgentManager {
} }
let desiredBind = self.preferredGatewayBind() ?? "loopback" let desiredBind = self.preferredGatewayBind() ?? "loopback"
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)") let desiredToken = self.preferredGatewayToken()
self.writePlist(bundlePath: bundlePath, port: port) let desiredPassword = self.preferredGatewayPassword()
let desiredConfig = DesiredConfig(port: port, bind: desiredBind, token: desiredToken, password: desiredPassword)
// If launchd already loaded the job (common on login), avoid `bootout` unless we must // If launchd already loaded the job (common on login), avoid `bootout` unless we must
// change the config. `bootout` can kill a just-started gateway and cause attach loops. // change the config. `bootout` can kill a just-started gateway and cause attach loops.
if let snapshot = await self.gatewayJobSnapshot(), let loaded = await self.isLoaded()
snapshot.matches(port: port, bind: desiredBind) if loaded,
let existing = self.readPlistConfig(),
existing.matches(desiredConfig)
{ {
self.logger.info("launchd job already loaded with desired config; skipping bootout") self.logger.info("launchd job already loaded with desired config; skipping bootout")
await self.ensureEnabled() await self.ensureEnabled()
_ = await self.runLaunchctl(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) _ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return nil return nil
} }
self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(bundlePath: bundlePath, port: port)
await self.ensureEnabled() await self.ensureEnabled()
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) if loaded {
let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 { if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)") self.logger.error("launchd bootstrap failed: \(msg)")
@@ -89,13 +97,14 @@ enum GatewayLaunchAgentManager {
} }
self.logger.info("launchd disable requested") self.logger.info("launchd disable requested")
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL) try? FileManager.default.removeItem(at: self.plistURL)
return nil return nil
} }
static func kickstart() async { static func kickstart() async {
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) _ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
} }
private static func writePlist(bundlePath: String, port: Int) { private static func writePlist(bundlePath: String, port: Int) {
@@ -221,40 +230,39 @@ enum GatewayLaunchAgentManager {
.replacingOccurrences(of: "'", with: "'") .replacingOccurrences(of: "'", with: "'")
} }
private struct LaunchctlResult { private struct DesiredConfig: Equatable {
let status: Int32 let port: Int
let output: String let bind: String
let token: String?
let password: String?
} }
struct LaunchdJobSnapshot: Equatable { private struct InstalledConfig: Equatable {
let pid: Int?
let port: Int? let port: Int?
let bind: String? let bind: String?
let token: String?
let password: String?
func matches(port: Int, bind: String) -> Bool { func matches(_ desired: DesiredConfig) -> Bool {
guard self.port == port else { return false } guard self.port == desired.port else { return false }
if let bindValue = self.bind { guard (self.bind ?? "loopback") == desired.bind else { return false }
return bindValue == bind guard self.token == desired.token else { return false }
} guard self.password == desired.password else { return false }
return true return true
} }
} }
static func parseLaunchctlPrintSnapshot(_ output: String) -> LaunchdJobSnapshot { private static func readPlistConfig() -> InstalledConfig? {
let pid = self.extractIntValue(output: output, key: "pid") guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
let port = self.extractFlagIntValue(output: output, flag: "--port") return InstalledConfig(
let bind = self.extractFlagStringValue(output: output, flag: "--bind")?.lowercased() port: snapshot.port,
return LaunchdJobSnapshot(pid: pid, port: port, bind: bind) bind: snapshot.bind,
} token: snapshot.token,
password: snapshot.password)
private static func gatewayJobSnapshot() async -> LaunchdJobSnapshot? {
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status == 0 else { return nil }
return self.parseLaunchctlPrintSnapshot(result.output)
} }
private static func ensureEnabled() async { private static func ensureEnabled() async {
let result = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard result.status != 0 else { return } guard result.status != 0 else { return }
let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines) let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
if msg.isEmpty { if msg.isEmpty {
@@ -264,65 +272,15 @@ enum GatewayLaunchAgentManager {
} }
} }
private static func extractIntValue(output: String, key: String) -> Int? { private static func ensureDisabled() async {
// launchctl print commonly emits `pid = 123` let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
guard let range = output.range(of: "\(key) =") else { return nil } guard result.status != 0 else { return }
var idx = range.upperBound let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
while idx < output.endIndex, output[idx].isWhitespace { idx = output.index(after: idx) } if msg.isEmpty {
var end = idx self.logger.warning("launchd disable failed")
while end < output.endIndex, output[end].isNumber { end = output.index(after: end) } } else {
guard end > idx else { return nil } self.logger.warning("launchd disable failed: \(msg)")
return Int(output[idx..<end])
}
private static func extractFlagIntValue(output: String, flag: String) -> Int? {
guard let raw = self.extractFlagStringValue(output: output, flag: flag) else { return nil }
return Int(raw)
}
private static func extractFlagStringValue(output: String, flag: String) -> String? {
guard let range = output.range(of: flag) else { return nil }
var idx = range.upperBound
while idx < output.endIndex {
let ch = output[idx]
if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "=" || ch == "\"" || ch == "'" {
idx = output.index(after: idx)
continue
}
break
} }
guard idx < output.endIndex else { return nil }
var end = idx
while end < output.endIndex {
let ch = output[end]
if ch.isWhitespace || ch == "," || ch == "(" || ch == ")" || ch == "\"" || ch == "'" || ch == "\n" || ch == "\r" {
break
}
end = output.index(after: end)
}
let token = output[idx..<end].trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : String(token)
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
await Task.detached(priority: .utility) { () -> LaunchctlResult in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return LaunchctlResult(status: process.terminationStatus, output: output)
} catch {
return LaunchctlResult(status: -1, output: error.localizedDescription)
}
}.value
} }
} }

View File

@@ -70,7 +70,7 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async { func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return } guard !CommandResolver.connectionModeIsRemote() else { return }
guard !AppStateStore.attachExistingGatewayOnly else { return } guard !AppStateStore.attachExistingGatewayOnly else { return }
let enabled = await GatewayLaunchAgentManager.status() let enabled = await GatewayLaunchAgentManager.isLoaded()
guard !enabled else { return } guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort() let port = GatewayEnvironment.gatewayPort()

View File

@@ -0,0 +1,82 @@
import Foundation
enum Launchctl {
struct Result: Sendable {
let status: Int32
let output: String
}
@discardableResult
static func run(_ args: [String]) async -> Result {
await Task.detached(priority: .utility) { () -> Result in
let process = Process()
process.launchPath = "/bin/launchctl"
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
return Result(status: process.terminationStatus, output: output)
} catch {
return Result(status: -1, output: error.localizedDescription)
}
}.value
}
}
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
let programArguments: [String]
let environment: [String: String]
let port: Int?
let bind: String?
let token: String?
let password: String?
}
enum LaunchAgentPlist {
static func snapshot(url: URL) -> LaunchAgentPlistSnapshot? {
guard let data = try? Data(contentsOf: url) else { return nil }
let rootAny: Any
do {
rootAny = try PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil)
} catch {
return nil
}
guard let root = rootAny as? [String: Any] else { return nil }
let programArguments = root["ProgramArguments"] as? [String] ?? []
let env = root["EnvironmentVariables"] as? [String: String] ?? [:]
let port = Self.extractFlagInt(programArguments, flag: "--port")
let bind = Self.extractFlagString(programArguments, flag: "--bind")?.lowercased()
let token = env["CLAWDBOT_GATEWAY_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let password = env["CLAWDBOT_GATEWAY_PASSWORD"]?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
return LaunchAgentPlistSnapshot(
programArguments: programArguments,
environment: env,
port: port,
bind: bind,
token: token,
password: password)
}
private static func extractFlagInt(_ args: [String], flag: String) -> Int? {
guard let raw = self.extractFlagString(args, flag: flag) else { return nil }
return Int(raw)
}
private static func extractFlagString(_ args: [String], flag: String) -> String? {
guard let idx = args.firstIndex(of: flag) else { return nil }
let valueIdx = args.index(after: idx)
guard valueIdx < args.endIndex else { return nil }
let token = args[valueIdx].trimmingCharacters(in: .whitespacesAndNewlines)
return token.isEmpty ? nil : token
}
}

View File

@@ -1,49 +1,41 @@
import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@Suite struct GatewayLaunchAgentManagerTests { @Suite struct GatewayLaunchAgentManagerTests {
@Test func parseLaunchctlPrintSnapshotParsesQuotedArgs() { @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
let output = """ let url = FileManager.default.temporaryDirectory
service = com.clawdbot.gateway .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
program arguments = ( let plist: [String: Any] = [
"/Applications/Clawdbot.app/Contents/Resources/Relay/clawdbot", "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
"gateway-daemon", "EnvironmentVariables": [
"--port", "CLAWDBOT_GATEWAY_TOKEN": " secret ",
"18789", "CLAWDBOT_GATEWAY_PASSWORD": "pw",
"--bind", ],
"loopback" ]
) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
pid = 123 try data.write(to: url, options: [.atomic])
""" defer { try? FileManager.default.removeItem(at: url) }
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
#expect(snapshot.pid == 123) let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789) #expect(snapshot.port == 18789)
#expect(snapshot.bind == "loopback") #expect(snapshot.bind == "loopback")
#expect(snapshot.matches(port: 18789, bind: "loopback")) #expect(snapshot.token == "secret")
#expect(snapshot.matches(port: 18789, bind: "tailnet") == false) #expect(snapshot.password == "pw")
#expect(snapshot.matches(port: 19999, bind: "loopback") == false)
} }
@Test func parseLaunchctlPrintSnapshotParsesUnquotedArgs() { @Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
let output = """ let url = FileManager.default.temporaryDirectory
argv[] = { /usr/local/bin/clawdbot, gateway-daemon, --port, 19999, --bind, tailnet } .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
pid = 0 let plist: [String: Any] = [
""" "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output) ]
#expect(snapshot.pid == 0) let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
#expect(snapshot.port == 19999) try data.write(to: url, options: [.atomic])
#expect(snapshot.bind == "tailnet") defer { try? FileManager.default.removeItem(at: url) }
}
@Test func parseLaunchctlPrintSnapshotAllowsMissingBind() { let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
let output = """
program arguments = ( "clawdbot", "gateway-daemon", "--port", "18789" )
pid = 456
"""
let snapshot = GatewayLaunchAgentManager.parseLaunchctlPrintSnapshot(output)
#expect(snapshot.port == 18789) #expect(snapshot.port == 18789)
#expect(snapshot.bind == nil) #expect(snapshot.bind == nil)
#expect(snapshot.matches(port: 18789, bind: "loopback"))
} }
} }