openclaw/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
Kenny Lee e119cfb468 fix(macos): disable gateway launchd job when switching to remote mode
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
2026-01-26 15:23:31 -08:00

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
}
}