Previously the stop() function had an early return when in remote mode, which skipped disabling the launchd job. This caused the gateway daemon to restart every 10 seconds due to KeepAlive, logging repeated 'Gateway start blocked' messages. Fixes #2433
206 lines
7.7 KiB
Swift
206 lines
7.7 KiB
Swift
import Foundation
|
|
|
|
enum GatewayLaunchAgentManager {
|
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
|
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
|
|
|
private static var disableLaunchAgentMarkerURL: URL {
|
|
FileManager().homeDirectoryForCurrentUser
|
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
|
}
|
|
|
|
private static var plistURL: URL {
|
|
FileManager().homeDirectoryForCurrentUser
|
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
|
}
|
|
|
|
static func isLaunchAgentWriteDisabled() -> Bool {
|
|
FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
|
|
}
|
|
|
|
static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
|
|
let marker = self.disableLaunchAgentMarkerURL
|
|
if disabled {
|
|
do {
|
|
try FileManager().createDirectory(
|
|
at: marker.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
if !FileManager().fileExists(atPath: marker.path) {
|
|
FileManager().createFile(atPath: marker.path, contents: nil)
|
|
}
|
|
} catch {
|
|
return error.localizedDescription
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if FileManager().fileExists(atPath: marker.path) {
|
|
do {
|
|
try FileManager().removeItem(at: marker)
|
|
} catch {
|
|
return error.localizedDescription
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func isLoaded() async -> Bool {
|
|
guard let loaded = await self.readDaemonLoaded() else { return false }
|
|
return loaded
|
|
}
|
|
|
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
|
_ = bundlePath
|
|
// In remote mode, skip enabling (the local gateway shouldn't run), but still
|
|
// allow disabling so the launchd job stops retrying every 10 seconds.
|
|
if enabled, CommandResolver.connectionModeIsRemote() {
|
|
self.logger.info("launchd enable skipped (remote mode)")
|
|
return nil
|
|
}
|
|
if enabled, self.isLaunchAgentWriteDisabled() {
|
|
self.logger.info("launchd enable skipped (disable marker set)")
|
|
return nil
|
|
}
|
|
|
|
if enabled {
|
|
self.logger.info("launchd enable requested via CLI port=\(port)")
|
|
return await self.runDaemonCommand([
|
|
"install",
|
|
"--force",
|
|
"--port",
|
|
"\(port)",
|
|
"--runtime",
|
|
"node",
|
|
])
|
|
}
|
|
|
|
self.logger.info("launchd disable requested via CLI")
|
|
return await self.runDaemonCommand(["uninstall"])
|
|
}
|
|
|
|
static func kickstart() async {
|
|
_ = await self.runDaemonCommand(["restart"], timeout: 20)
|
|
}
|
|
|
|
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
|
|
LaunchAgentPlist.snapshot(url: self.plistURL)
|
|
}
|
|
|
|
static func launchdGatewayLogPath() -> String {
|
|
let snapshot = self.launchdConfigSnapshot()
|
|
if let stdout = snapshot?.stdoutPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!stdout.isEmpty
|
|
{
|
|
return stdout
|
|
}
|
|
if let stderr = snapshot?.stderrPath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!stderr.isEmpty
|
|
{
|
|
return stderr
|
|
}
|
|
return LogLocator.launchdGatewayLogPath
|
|
}
|
|
}
|
|
|
|
extension GatewayLaunchAgentManager {
|
|
private static func readDaemonLoaded() async -> Bool? {
|
|
let result = await self.runDaemonCommandResult(
|
|
["status", "--json", "--no-probe"],
|
|
timeout: 15,
|
|
quiet: true)
|
|
guard result.success, let payload = result.payload else { return nil }
|
|
guard
|
|
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
|
let service = json["service"] as? [String: Any],
|
|
let loaded = service["loaded"] as? Bool
|
|
else {
|
|
return nil
|
|
}
|
|
return loaded
|
|
}
|
|
|
|
private struct CommandResult {
|
|
let success: Bool
|
|
let payload: Data?
|
|
let message: String?
|
|
}
|
|
|
|
private struct ParsedDaemonJson {
|
|
let text: String
|
|
let object: [String: Any]
|
|
}
|
|
|
|
private static func runDaemonCommand(
|
|
_ args: [String],
|
|
timeout: Double = 15,
|
|
quiet: Bool = false) async -> String?
|
|
{
|
|
let result = await self.runDaemonCommandResult(args, timeout: timeout, quiet: quiet)
|
|
if result.success { return nil }
|
|
return result.message ?? "Gateway daemon command failed"
|
|
}
|
|
|
|
private static func runDaemonCommandResult(
|
|
_ args: [String],
|
|
timeout: Double,
|
|
quiet: Bool) async -> CommandResult
|
|
{
|
|
let command = CommandResolver.clawdbotCommand(
|
|
subcommand: "gateway",
|
|
extraArgs: self.withJsonFlag(args),
|
|
// Launchd management must always run locally, even if remote mode is configured.
|
|
configRoot: ["gateway": ["mode": "local"]])
|
|
var env = ProcessInfo.processInfo.environment
|
|
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
|
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
|
let parsed = self.parseDaemonJson(from: response.stdout) ?? self.parseDaemonJson(from: response.stderr)
|
|
let ok = parsed?.object["ok"] as? Bool
|
|
let message = (parsed?.object["error"] as? String) ?? (parsed?.object["message"] as? String)
|
|
let payload = parsed?.text.data(using: .utf8)
|
|
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
|
let success = ok ?? response.success
|
|
if success {
|
|
return CommandResult(success: true, payload: payload, message: nil)
|
|
}
|
|
|
|
if quiet {
|
|
return CommandResult(success: false, payload: payload, message: message)
|
|
}
|
|
|
|
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
|
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
|
let fullMessage = detail.map { "Gateway daemon command failed (\(exit)): \($0)" }
|
|
?? "Gateway daemon command failed (\(exit))"
|
|
self.logger.error("\(fullMessage, privacy: .public)")
|
|
return CommandResult(success: false, payload: payload, message: detail)
|
|
}
|
|
|
|
private static func withJsonFlag(_ args: [String]) -> [String] {
|
|
if args.contains("--json") { return args }
|
|
return args + ["--json"]
|
|
}
|
|
|
|
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let start = trimmed.firstIndex(of: "{"),
|
|
let end = trimmed.lastIndex(of: "}")
|
|
else {
|
|
return nil
|
|
}
|
|
let jsonText = String(trimmed[start...end])
|
|
guard let data = jsonText.data(using: .utf8) else { return nil }
|
|
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
|
return ParsedDaemonJson(text: jsonText, object: object)
|
|
}
|
|
|
|
private static func summarize(_ text: String) -> String? {
|
|
let lines = text
|
|
.split(whereSeparator: \.isNewline)
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
guard let last = lines.last else { return nil }
|
|
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
|
}
|
|
}
|