import Foundation enum GatewayLaunchAgentManager { private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd") private static let supportedBindModes: Set = ["loopback", "tailnet", "lan", "auto"] private static let legacyGatewayLaunchdLabel = "com.steipete.clawdbot.gateway" private static var plistURL: URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") } private static var legacyPlistURL: URL { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/\(legacyGatewayLaunchdLabel).plist") } private static func gatewayExecutablePath(bundlePath: String) -> String { "\(bundlePath)/Contents/Resources/Relay/clawdbot" } private static func relayDir(bundlePath: String) -> String { "\(bundlePath)/Contents/Resources/Relay" } private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] { #if DEBUG let projectRoot = CommandResolver.projectRoot() if let localBin = CommandResolver.projectClawdbotExecutable(projectRoot: projectRoot) { return [localBin, "gateway", "--port", "\(port)", "--bind", bind] } if let entry = CommandResolver.gatewayEntrypoint(in: projectRoot), case let .success(runtime) = CommandResolver.runtimeResolution() { return CommandResolver.makeRuntimeCommand( runtime: runtime, entrypoint: entry, subcommand: "gateway", extraArgs: ["--port", "\(port)", "--bind", bind]) } #endif let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] } static func status() async -> Bool { guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) return result.status == 0 } static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { if enabled { _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"]) try? FileManager.default.removeItem(at: self.legacyPlistURL) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) guard FileManager.default.isExecutableFile(atPath: gatewayBin) else { self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)") return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh" } let desiredBind = self.preferredGatewayBind() ?? "loopback" self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)") self.writePlist(bundlePath: bundlePath, port: port) // 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. if let snapshot = await self.gatewayJobSnapshot(), snapshot.matches(port: port, bind: desiredBind) { self.logger.info("launchd job already loaded with desired config; skipping bootout") await self.ensureEnabled() _ = await self.runLaunchctl(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) return nil } await self.ensureEnabled() _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) if bootstrap.status != 0 { let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) self.logger.error("launchd bootstrap failed: \(msg)") return bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Failed to bootstrap gateway launchd job" : bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines) } await self.ensureEnabled() return nil } self.logger.info("launchd disable requested") _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) try? FileManager.default.removeItem(at: self.plistURL) return nil } static func kickstart() async { _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) } private static func writePlist(bundlePath: String, port: Int) { let relayDir = self.relayDir(bundlePath: bundlePath) let preferredPath = ([relayDir] + CommandResolver.preferredPaths()) .joined(separator: ":") let bind = self.preferredGatewayBind() ?? "loopback" let programArguments = self.gatewayProgramArguments(bundlePath: bundlePath, port: port, bind: bind) let token = self.preferredGatewayToken() let password = self.preferredGatewayPassword() var envEntries = """ PATH \(preferredPath) CLAWDBOT_IMAGE_BACKEND sips """ if let token { let escapedToken = self.escapePlistValue(token) envEntries += """ CLAWDBOT_GATEWAY_TOKEN \(escapedToken) """ } if let password { let escapedPassword = self.escapePlistValue(password) envEntries += """ CLAWDBOT_GATEWAY_PASSWORD \(escapedPassword) """ } let argsXml = programArguments .map { "\(self.escapePlistValue($0))" } .joined(separator: "\n ") let plist = """ Label \(gatewayLaunchdLabel) ProgramArguments \(argsXml) WorkingDirectory \(FileManager.default.homeDirectoryForCurrentUser.path) RunAtLoad KeepAlive EnvironmentVariables \(envEntries) StandardOutPath \(LogLocator.launchdGatewayLogPath) StandardErrorPath \(LogLocator.launchdGatewayLogPath) """ do { try plist.write(to: self.plistURL, atomically: true, encoding: .utf8) } catch { self.logger.error("launchd plist write failed: \(error.localizedDescription)") } } private static func preferredGatewayBind() -> String? { if CommandResolver.connectionModeIsRemote() { return nil } if let env = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_BIND"] { let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if self.supportedBindModes.contains(trimmed) { return trimmed } } let root = ClawdbotConfigFile.loadDict() if let gateway = root["gateway"] as? [String: Any], let bind = gateway["bind"] as? String { let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if self.supportedBindModes.contains(trimmed) { return trimmed } } return nil } private static func preferredGatewayToken() -> String? { let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"] ?? "" let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private static func preferredGatewayPassword() -> String? { // First check environment variable let raw = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"] ?? "" let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } // Then check config file (gateway.auth.password) let root = ClawdbotConfigFile.loadDict() if let gateway = root["gateway"] as? [String: Any], let auth = gateway["auth"] as? [String: Any], let password = auth["password"] as? String { return password.trimmingCharacters(in: .whitespacesAndNewlines) } return nil } private static func escapePlistValue(_ raw: String) -> String { raw .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) .replacingOccurrences(of: "'", with: "'") } private struct LaunchctlResult { let status: Int32 let output: String } struct LaunchdJobSnapshot: Equatable { let pid: Int? let port: Int? let bind: String? func matches(port: Int, bind: String) -> Bool { guard self.port == port else { return false } if let bindValue = self.bind { return bindValue == bind } return true } } static func parseLaunchctlPrintSnapshot(_ output: String) -> LaunchdJobSnapshot { let pid = self.extractIntValue(output: output, key: "pid") let port = self.extractFlagIntValue(output: output, flag: "--port") let bind = self.extractFlagStringValue(output: output, flag: "--bind")?.lowercased() return LaunchdJobSnapshot(pid: pid, port: port, bind: bind) } 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 { let result = await self.runLaunchctl(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"]) guard result.status != 0 else { return } let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines) if msg.isEmpty { self.logger.warning("launchd enable failed") } else { self.logger.warning("launchd enable failed: \(msg)") } } private static func extractIntValue(output: String, key: String) -> Int? { // launchctl print commonly emits `pid = 123` guard let range = output.range(of: "\(key) =") else { return nil } var idx = range.upperBound while idx < output.endIndex, output[idx].isWhitespace { idx = output.index(after: idx) } var end = idx while end < output.endIndex, output[end].isNumber { end = output.index(after: end) } guard end > idx else { return nil } return Int(output[idx.. 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.. 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 } } #if DEBUG extension GatewayLaunchAgentManager { static func _testGatewayExecutablePath(bundlePath: String) -> String { self.gatewayExecutablePath(bundlePath: bundlePath) } static func _testRelayDir(bundlePath: String) -> String { self.relayDir(bundlePath: bundlePath) } static func _testPreferredGatewayBind() -> String? { self.preferredGatewayBind() } static func _testPreferredGatewayToken() -> String? { self.preferredGatewayToken() } static func _testEscapePlistValue(_ raw: String) -> String { self.escapePlistValue(raw) } } #endif