import CryptoKit import Foundation import OpenClawChatUI import OpenClawKit import OpenClawProtocol import OSLog import Security private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection") private enum GatewayActivationBindingKeyStore { private static let service = "ai.openclaw.onboarding-route-binding" private static let account = "credential-binding-v1" private static let byteCount = 32 static func loadOrCreate() -> SymmetricKey? { if let data = load() { return SymmetricKey(data: data) } var data = Data(count: byteCount) let randomStatus = data.withUnsafeMutableBytes { bytes in guard let baseAddress = bytes.baseAddress else { return errSecAllocate } return SecRandomCopyBytes(kSecRandomDefault, self.byteCount, baseAddress) } guard randomStatus == errSecSuccess else { return nil } var query = self.baseQuery query[kSecValueData as String] = data query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let addStatus = SecItemAdd(query as CFDictionary, nil) if addStatus == errSecSuccess { return SymmetricKey(data: data) } // Another process can win the first-launch create race. Only accept the // secret after reading the Keychain item back through normal ACL checks. if addStatus == errSecDuplicateItem, let existing = load() { return SymmetricKey(data: existing) } return nil } private static func load() -> Data? { var query = self.baseQuery query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data, data.count == byteCount else { return nil } return data } private static var baseQuery: [String: Any] { [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false, ] } } /// Single, shared Gateway websocket connection for the whole app. /// /// This owns exactly one `GatewayChannelActor` and reuses it across all callers /// (ControlChannel, debug actions, SwiftUI WebChat, etc.). actor GatewayConnection { static let shared = GatewayConnection( endpointProvider: GatewayConnection.defaultEndpointProvider) typealias Config = (url: URL, token: String?, password: String?) struct EndpointSnapshot { let config: Config let routeAuthority: UInt64? let deviceAuthGatewayID: String? let revision: UInt64? init( config: Config, routeAuthority: UInt64?, deviceAuthGatewayID: String? = nil, revision: UInt64? = nil) { self.config = config self.routeAuthority = routeAuthority self.deviceAuthGatewayID = deviceAuthGatewayID self.revision = revision } } typealias EndpointProvider = @Sendable () async throws -> EndpointSnapshot struct Route: Equatable { fileprivate let generation: UInt64 fileprivate let authority: UInt64? fileprivate let url: URL fileprivate let token: String? fileprivate let password: String? fileprivate let deviceAuthGatewayID: String? let activationOwnershipFingerprint: String? fileprivate func matches( _ config: Config, authority: UInt64?, deviceAuthGatewayID: String?) -> Bool { self.authority == authority && self.url == config.url && self.token == config.token && self.password == config.password && self.deviceAuthGatewayID == deviceAuthGatewayID } } /// One connected Gateway server, not merely an endpoint configuration. /// A reconnect at the same URL creates a different lease. struct ServerLease { fileprivate let route: Route fileprivate let socketGeneration: UInt64 fileprivate let client: GatewayChannelActor } struct SessionRoutingIdentity: Equatable { let defaultAgentID: String let contract: String } enum Method: String { case agent case status case setHeartbeats = "set-heartbeats" case systemEvent = "system-event" case health case channelsStatus = "channels.status" case configGet = "config.get" case configSet = "config.set" case configPatch = "config.patch" case configSchema = "config.schema" case configSchemaLookup = "config.schema.lookup" case wizardStart = "wizard.start" case wizardNext = "wizard.next" case wizardCancel = "wizard.cancel" case wizardStatus = "wizard.status" case talkConfig = "talk.config" case talkMode = "talk.mode" case talkSpeak = "talk.speak" case webLoginStart = "web.login.start" case webLoginWait = "web.login.wait" case channelsLogout = "channels.logout" case modelsList = "models.list" case chatHistory = "chat.history" case sessionsPreview = "sessions.preview" case chatSend = "chat.send" case skillsStatus = "skills.status" case skillsInstall = "skills.install" case skillsUpdate = "skills.update" case voicewakeGet = "voicewake.get" case voicewakeSet = "voicewake.set" case nodePairApprove = "node.pair.approve" case nodePairReject = "node.pair.reject" case devicePairList = "device.pair.list" case devicePairApprove = "device.pair.approve" case devicePairReject = "device.pair.reject" case execApprovalResolve = "exec.approval.resolve" case cronList = "cron.list" case cronRuns = "cron.runs" case cronRun = "cron.run" case cronRemove = "cron.remove" case cronUpdate = "cron.update" case cronAdd = "cron.add" case cronStatus = "cron.status" } private let endpointProvider: EndpointProvider private let activationBindingKeyProvider: @Sendable () -> SymmetricKey? private let sessionBox: WebSocketSessionBox? private let clientShutdown: @Sendable (GatewayChannelActor) async -> Void private let decoder = JSONDecoder() private var client: GatewayChannelActor? private var configuredURL: URL? private var configuredToken: String? private var configuredPassword: String? private var configuredDeviceAuthGatewayID: String? private var configuredRouteAuthority: UInt64? private var configuredShutdownGeneration: UInt64? private var configuredActivationBindingKey: SymmetricKey? private var highestEndpointRevision: UInt64? private var routeGeneration: UInt64 = 0 /// Unbound operations capture this before their first suspension. Shutdown /// advances it so delayed config and retry work cannot recreate a route. private var shutdownGeneration: UInt64 = 0 // Callback work keeps the physical socket epoch that decoded it. Retiring // that epoch prevents delayed pushes from entering a replacement socket. private var activeSocketGeneration: UInt64? private var lastRetiredSocketGeneration: UInt64? private var subscribers: [UUID: AsyncStream.Continuation] = [:] private var lastSnapshot: HelloOk? private struct LossyDecodable: Decodable { let value: Value? init(from decoder: Decoder) throws { do { self.value = try Value(from: decoder) } catch { self.value = nil } } } private struct LossyCronListResponse: Decodable { let jobs: [LossyDecodable] enum CodingKeys: String, CodingKey { case jobs } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.jobs = try container.decodeIfPresent([LossyDecodable].self, forKey: .jobs) ?? [] } } private struct LossyCronRunsResponse: Decodable { let entries: [LossyDecodable] enum CodingKeys: String, CodingKey { case entries } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.entries = try container.decodeIfPresent([LossyDecodable].self, forKey: .entries) ?? [] } } init( endpointProvider: @escaping EndpointProvider = GatewayConnection.defaultEndpointProvider, activationBindingKeyProvider: @escaping @Sendable () -> SymmetricKey? = GatewayConnection.defaultActivationBindingKey, sessionBox: WebSocketSessionBox? = nil, clientShutdown: @escaping @Sendable (GatewayChannelActor) async -> Void = { client in await client.shutdown() }) { self.endpointProvider = endpointProvider self.activationBindingKeyProvider = activationBindingKeyProvider self.sessionBox = sessionBox self.clientShutdown = clientShutdown } #if DEBUG private static let testingActivationBindingKey = SymmetricKey(size: .bits256) init( configProvider: @escaping @Sendable () async throws -> Config, activationBindingKeyProvider: @escaping @Sendable () -> SymmetricKey? = { GatewayConnection.testingActivationBindingKey }, sessionBox: WebSocketSessionBox? = nil, clientShutdown: @escaping @Sendable (GatewayChannelActor) async -> Void = { client in await client.shutdown() }) { self.endpointProvider = { try await EndpointSnapshot(config: configProvider(), routeAuthority: nil) } self.activationBindingKeyProvider = activationBindingKeyProvider self.sessionBox = sessionBox self.clientShutdown = clientShutdown } #endif // MARK: - Low-level request func request( method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil, retryTransportFailures: Bool = true) async throws -> Data { let shutdownGeneration = shutdownGeneration let endpoint = try await currentEndpoint() let cfg = endpoint.config let client = try await configure( url: cfg.url, token: cfg.token, password: cfg.password, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) do { return try await client.request(method: method, params: params, timeoutMs: timeoutMs) } catch { if !retryTransportFailures || error is GatewayResponseError || error is GatewayDecodingError { throw error } try requireCurrentShutdownGeneration(shutdownGeneration) // Auto-recover in local mode by spawning/attaching a gateway and retrying a few times. // Canvas interactions should "just work" even if the local gateway isn't running yet. let mode = await MainActor.run { AppStateStore.shared.connectionMode } try requireCurrentShutdownGeneration(shutdownGeneration) switch mode { case .local: await MainActor.run { GatewayProcessManager.shared.setActive(true) } try requireCurrentShutdownGeneration(shutdownGeneration) var lastError: Error = error for delayMs in [150, 400, 900] { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) try requireCurrentShutdownGeneration(shutdownGeneration) do { return try await client.request(method: method, params: params, timeoutMs: timeoutMs) } catch { try requireCurrentShutdownGeneration(shutdownGeneration) lastError = error } } let nsError = lastError as NSError try requireCurrentShutdownGeneration(shutdownGeneration) if nsError.domain == URLError.errorDomain, let fallback = await GatewayEndpointStore.shared.maybeFallbackToTailnet(from: cfg.url) { try acceptEndpointRevision(fallback) let fallbackConfig = fallback.config let fallbackClient = try await configure( url: fallbackConfig.url, token: fallbackConfig.token, password: fallbackConfig.password, deviceAuthGatewayID: fallback.deviceAuthGatewayID, routeAuthority: fallback.routeAuthority, shutdownGeneration: shutdownGeneration) for delayMs in [150, 400, 900] { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) try requireCurrentShutdownGeneration(shutdownGeneration) do { return try await fallbackClient.request( method: method, params: params, timeoutMs: timeoutMs) } catch { try requireCurrentShutdownGeneration(shutdownGeneration) lastError = error } } } try requireCurrentShutdownGeneration(shutdownGeneration) throw lastError case .remote: let nsError = error as NSError guard nsError.domain == URLError.errorDomain else { throw error } var lastError: Error = error await RemoteTunnelManager.shared.stopAll() try requireCurrentShutdownGeneration(shutdownGeneration) do { _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() try requireCurrentShutdownGeneration(shutdownGeneration) } catch { try requireCurrentShutdownGeneration(shutdownGeneration) lastError = error } for delayMs in [150, 400, 900] { try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) try requireCurrentShutdownGeneration(shutdownGeneration) do { let endpoint = try await currentEndpoint() let cfg = endpoint.config let client = try await configure( url: cfg.url, token: cfg.token, password: cfg.password, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) return try await client.request(method: method, params: params, timeoutMs: timeoutMs) } catch { try requireCurrentShutdownGeneration(shutdownGeneration) lastError = error } } try requireCurrentShutdownGeneration(shutdownGeneration) throw lastError case .unconfigured: throw error } } } /// Route-bound requests never reconfigure or retry on another endpoint. /// Durable outbox rows must remain owned by the gateway that created them. func request( method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil, ifCurrentRoute route: Route, distinguishPreDispatchRouteChange: Bool = false) async throws -> Data { let endpoint = try await currentEndpoint() let cfg = endpoint.config guard route.generation == self.routeGeneration, route.matches( cfg, authority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID), self.configuredURL == route.url, self.configuredToken == route.token, self.configuredPassword == route.password, self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID, self.configuredRouteAuthority == route.authority, let client else { if distinguishPreDispatchRouteChange { throw OpenClawChatTransportSendError.notDispatched } throw CancellationError() } return try await client.request(method: method, params: params, timeoutMs: timeoutMs) } /// Server-bound requests never reconfigure, reconnect, or cross onto a /// replacement socket at the same endpoint. func request( method: String, params: [String: AnyCodable]?, timeoutMs: Double? = nil, ifCurrentServerLease lease: ServerLease) async throws -> Data { guard await isCurrentServerLease(lease) else { throw OpenClawChatTransportSendError.notDispatched } do { return try await lease.client.request( method: method, params: params, timeoutMs: timeoutMs, ifCurrentConnectionGeneration: lease.socketGeneration) } catch is CancellationError { if Task.isCancelled { throw CancellationError() } throw OpenClawChatTransportSendError.notDispatched } } } extension GatewayConnection { enum WizardCancellationOutcome: Equatable { case cancelled case absent case unresolved } /// Cancel on the socket that created the wizard, or a replacement socket on /// the same route. Absence stays distinct so callers can reconcile a commit. @discardableResult func cancelWizardSession( _ sessionID: String, on lease: ServerLease) async -> WizardCancellationOutcome { let initial = await sendWizardCancellation(sessionID, on: lease) if initial != .unresolved { return initial } guard let replacement = try? await acquireServerLease( ifSameRouteAs: lease, timeoutMs: 5000) else { return .unresolved } return await self.sendWizardCancellation(sessionID, on: replacement) } private func sendWizardCancellation( _ sessionID: String, on lease: ServerLease) async -> WizardCancellationOutcome { do { let data = try await lease.client.request( method: "wizard.cancel", params: ["sessionId": AnyCodable(sessionID)], timeoutMs: 10000, ifCurrentConnectionGeneration: lease.socketGeneration) let status = try decoder.decode(WizardCancellationStatus.self, from: data) return switch status.status { case "cancelled": .cancelled case "running": .unresolved default: .absent } } catch { return Self.wizardCancellationOutcome(after: error) } } static func wizardCancellationOutcome(after error: Error) -> WizardCancellationOutcome { guard let response = error as? GatewayResponseError else { return .unresolved } let sessionIsAbsent = response.method == "wizard.cancel" && response.code == "INVALID_REQUEST" && response.message == "wizard not found" return sessionIsAbsent ? .absent : .unresolved } private struct WizardCancellationStatus: Decodable { let status: String } func requestRaw( method: Method, params: [String: AnyCodable]? = nil, timeoutMs: Double? = nil) async throws -> Data { try await self.request(method: method.rawValue, params: params, timeoutMs: timeoutMs) } func requestRaw( method: String, params: [String: AnyCodable]? = nil, timeoutMs: Double? = nil) async throws -> Data { try await self.request(method: method, params: params, timeoutMs: timeoutMs) } func requestDecoded( method: Method, params: [String: AnyCodable]? = nil, timeoutMs: Double? = nil) async throws -> T { let data = try await requestRaw(method: method, params: params, timeoutMs: timeoutMs) do { return try self.decoder.decode(T.self, from: data) } catch { throw GatewayDecodingError(method: method.rawValue, message: error.localizedDescription) } } func requestVoid( method: Method, params: [String: AnyCodable]? = nil, timeoutMs: Double? = nil) async throws { _ = try await self.requestRaw(method: method, params: params, timeoutMs: timeoutMs) } /// Ensure the underlying socket is configured (and replaced if config changed). func refresh() async throws { let shutdownGeneration = shutdownGeneration let endpoint = try await currentEndpoint() let cfg = endpoint.config _ = try await self.configure( url: cfg.url, token: cfg.token, password: cfg.password, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) } func captureRoute() async -> Route? { let shutdownGeneration = shutdownGeneration do { let endpoint = try await currentEndpoint() let cfg = endpoint.config _ = try await self.configure( url: cfg.url, token: cfg.token, password: cfg.password, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) return Route( generation: self.routeGeneration, authority: endpoint.routeAuthority, url: cfg.url, token: cfg.token, password: cfg.password, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, activationOwnershipFingerprint: Self.activationOwnershipFingerprint( config: cfg, key: self.configuredActivationBindingKey)) } catch { return nil } } /// Connect and bind subsequent work to the hello snapshot's physical /// socket. The read-only health preflight intentionally uses the ordinary /// recovery path so a fresh local Gateway can start and a remote tunnel can /// recover before onboarding freezes the successful physical connection. func acquireServerLease() async throws -> ServerLease { try await self.acquireServerLease(timeoutMs: 15000, retryTransportFailures: true) } private func acquireServerLease( timeoutMs: Double, retryTransportFailures: Bool) async throws -> ServerLease { let shutdownGeneration = self.shutdownGeneration _ = try await self.request( method: Method.health.rawValue, params: nil, timeoutMs: timeoutMs, retryTransportFailures: retryTransportFailures) try self.requireCurrentShutdownGeneration(shutdownGeneration) let endpoint = try await currentEndpoint() let cfg = endpoint.config guard let client = configuredClient( url: cfg.url, token: cfg.token, password: cfg.password, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, routeAuthority: endpoint.routeAuthority, shutdownGeneration: shutdownGeneration) else { throw OpenClawChatTransportSendError.notDispatched } guard let socketGeneration = await client.currentConnectionGeneration() else { throw OpenClawChatTransportSendError.notDispatched } guard let authBinding = await client.authBinding( ifCurrentConnectionGeneration: socketGeneration) else { throw OpenClawChatTransportSendError.notDispatched } let lease = ServerLease( route: Route( generation: routeGeneration, authority: endpoint.routeAuthority, url: cfg.url, token: cfg.token, password: cfg.password, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, activationOwnershipFingerprint: Self.activationOwnershipFingerprint( config: cfg, authBinding: authBinding, key: self.configuredActivationBindingKey)), socketGeneration: socketGeneration, client: client) guard await self.isCurrentServerLease(lease) else { throw OpenClawChatTransportSendError.notDispatched } return lease } /// Reconnect one physical socket without crossing the route that owned the /// original request. Endpoint or credential changes must start fresh work. func acquireServerLease( ifSameRouteAs previous: ServerLease, timeoutMs: Double) async throws -> ServerLease { guard await self.isCurrentRoute(previous.route) else { throw OpenClawChatTransportSendError.notDispatched } // Restart reconciliation owns its overall deadline. One short, non-retrying // health request keeps a failed attempt from consuming the whole budget. let replacement = try await acquireServerLease( timeoutMs: timeoutMs, retryTransportFailures: false) guard replacement.route.generation == previous.route.generation, replacement.route.url == previous.route.url, replacement.route.token == previous.route.token, replacement.route.password == previous.route.password, replacement.route.deviceAuthGatewayID == previous.route.deviceAuthGatewayID else { throw OpenClawChatTransportSendError.notDispatched } return replacement } func isCurrentRoute(_ route: Route) async -> Bool { guard let endpoint = try? await currentEndpoint() else { return false } let cfg = endpoint.config return route.generation == self.routeGeneration && route.matches( cfg, authority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID) && self.configuredURL == route.url && self.configuredToken == route.token && self.configuredPassword == route.password && self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID && self.configuredRouteAuthority == route.authority } func supportsServerCapability( _ capability: GatewayServerCapability, ifCurrentRoute route: Route) async -> Bool? { guard let endpoint = try? await currentEndpoint() else { return nil } let cfg = endpoint.config guard route.generation == self.routeGeneration, route.matches( cfg, authority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID), self.configuredURL == route.url, self.configuredToken == route.token, self.configuredPassword == route.password, self.configuredDeviceAuthGatewayID == route.deviceAuthGatewayID, self.configuredRouteAuthority == route.authority, let snapshot = lastSnapshot else { return nil } return snapshot.supportsServerCapability(capability) } func supportsServerCapability( _ capability: GatewayServerCapability, ifCurrentServerLease lease: ServerLease) async -> Bool? { guard await self.isCurrentServerLease(lease), self.serverLeaseMatchesCurrentState(lease), let snapshot = lastSnapshot else { return nil } return snapshot.supportsServerCapability(capability) } func isCurrentServerLease(_ lease: ServerLease) async -> Bool { guard let endpoint = try? await currentEndpoint(), serverLeaseMatchesCurrentState(lease), lease.route.matches( endpoint.config, authority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID), await lease.client.currentConnectionGeneration() == lease.socketGeneration, serverLeaseMatchesCurrentState(lease) else { return false } return true } func activationOwnershipFingerprint( ifCurrentServerLease lease: ServerLease) async -> String? { guard await self.isCurrentServerLease(lease) else { return nil } return lease.route.activationOwnershipFingerprint } private func serverLeaseMatchesCurrentState(_ lease: ServerLease) -> Bool { lease.route.generation == self.routeGeneration && lease.route.url == self.configuredURL && lease.route.token == self.configuredToken && lease.route.password == self.configuredPassword && lease.route.deviceAuthGatewayID == self.configuredDeviceAuthGatewayID && lease.route.authority == self.configuredRouteAuthority && self.client === lease.client && self.activeSocketGeneration == lease.socketGeneration && self.lastSnapshot != nil } func sessionRoutingIdentity(ifCurrentRoute route: Route) async throws -> SessionRoutingIdentity { let data = try await request( method: "agents.list", params: [:], timeoutMs: 15000, ifCurrentRoute: route) return try Self.decodeSessionRoutingIdentity(data) } static func decodeSessionRoutingIdentity(_ data: Data) throws -> SessionRoutingIdentity { let result = try JSONDecoder().decode(AgentsListResult.self, from: data) guard let contract = OpenClawChatSessionRoutingContract.make( scope: result.scope.value as? String, mainKey: result.mainkey, defaultAgentID: result.defaultid) else { throw CancellationError() } return SessionRoutingIdentity(defaultAgentID: result.defaultid, contract: contract) } func configuredInferenceModel( ifCurrentRoute route: Route, timeoutMs: Double = 15000) async throws -> String? { let data = try await request( method: "agents.list", params: [:], timeoutMs: timeoutMs, ifCurrentRoute: route) guard await self.isCurrentRoute(route) else { throw CancellationError() } return try Self.decodeConfiguredInferenceModel(data) } static func decodeConfiguredInferenceModel(_ data: Data) throws -> String? { let result = try JSONDecoder().decode(AgentsListResult.self, from: data) let primary = result.agents .first(where: { $0.id == result.defaultid })? .model?["primary"]?.value as? String let trimmed = primary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } func authSource() async -> GatewayAuthSource? { guard let client else { return nil } return await client.authSource() } func shutdown() async { self.shutdownGeneration &+= 1 self.routeGeneration &+= 1 resetSocketGeneration() self.lastSnapshot = nil let client = client self.client = nil self.configuredURL = nil self.configuredToken = nil self.configuredPassword = nil self.configuredDeviceAuthGatewayID = nil self.configuredRouteAuthority = nil self.configuredShutdownGeneration = nil self.configuredActivationBindingKey = nil if let client { await self.clientShutdown(client) } } private func configure( url: URL, token: String?, password: String?, deviceAuthGatewayID: String?, routeAuthority: UInt64?, shutdownGeneration: UInt64) async throws -> GatewayChannelActor { try self.requireCurrentShutdownGeneration(shutdownGeneration) if let client = configuredClient( url: url, token: token, password: password, deviceAuthGatewayID: deviceAuthGatewayID, routeAuthority: routeAuthority, shutdownGeneration: shutdownGeneration) { return client } // Invalidate captured routes before suspension so no reentrant caller // can continue an old-gateway outbox flush during replacement. self.routeGeneration &+= 1 resetSocketGeneration() self.lastSnapshot = nil let configuredRouteGeneration = self.routeGeneration let previousClient = client client = nil self.configuredURL = nil self.configuredToken = nil self.configuredPassword = nil self.configuredDeviceAuthGatewayID = nil self.configuredRouteAuthority = nil self.configuredShutdownGeneration = nil self.configuredActivationBindingKey = nil if let previousClient { await self.clientShutdown(previousClient) } try self.requireCurrentShutdownGeneration(shutdownGeneration) if self.routeGeneration != configuredRouteGeneration { if let client = configuredClient( url: url, token: token, password: password, deviceAuthGatewayID: deviceAuthGatewayID, routeAuthority: routeAuthority, shutdownGeneration: shutdownGeneration) { return client } throw CancellationError() } let activationBindingKey = self.activationBindingKeyProvider() let client = GatewayChannelActor( url: url, token: token, password: password, authBindingKey: activationBindingKey, session: sessionBox, connectSnapshotAdmissionHandler: { [weak self] snapshot, socketGeneration in await self?.admitConnectSnapshot( snapshot, routeGeneration: configuredRouteGeneration, socketGeneration: socketGeneration) }, pushHandler: { [weak self] push, socketGeneration in await self?.handle( push: push, routeGeneration: configuredRouteGeneration, socketGeneration: socketGeneration) }, connectOptions: GatewayConnectOptions( role: "operator", scopes: GatewayChannelActor.defaultOperatorConnectScopes, caps: [], commands: [], permissions: [:], clientId: "openclaw-macos", clientMode: "ui", clientDisplayName: InstanceIdentity.displayName, allowStoredDeviceAuth: deviceAuthGatewayID != nil, deviceAuthGatewayID: deviceAuthGatewayID), disconnectHandler: { [weak self] _, socketGeneration in await self?.handleDisconnect( routeGeneration: configuredRouteGeneration, socketGeneration: socketGeneration) }) self.client = client self.configuredURL = url self.configuredToken = token self.configuredPassword = password self.configuredDeviceAuthGatewayID = deviceAuthGatewayID self.configuredRouteAuthority = routeAuthority self.configuredShutdownGeneration = shutdownGeneration self.configuredActivationBindingKey = activationBindingKey return client } private func configuredClient( url: URL, token: String?, password: String?, deviceAuthGatewayID: String?, routeAuthority: UInt64?, shutdownGeneration: UInt64) -> GatewayChannelActor? { guard self.configuredShutdownGeneration == shutdownGeneration, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password, self.configuredDeviceAuthGatewayID == deviceAuthGatewayID, self.configuredRouteAuthority == routeAuthority else { return nil } return self.client } private func requireCurrentShutdownGeneration(_ shutdownGeneration: UInt64) throws { guard self.shutdownGeneration == shutdownGeneration else { throw CancellationError() } } private func handle( push: GatewayPush, routeGeneration: UInt64, socketGeneration: UInt64) { guard routeGeneration == self.routeGeneration, admitSocketGeneration(socketGeneration) else { return } broadcast(push) } /// Short connect-path admission only. Subscriber delivery stays on the /// ordinary push task so connect never waits on downstream UI work. private func admitConnectSnapshot( _ snapshot: HelloOk, routeGeneration: UInt64, socketGeneration: UInt64) { guard routeGeneration == self.routeGeneration, admitSocketGeneration(socketGeneration) else { return } self.lastSnapshot = snapshot } private func handleDisconnect(routeGeneration: UInt64, socketGeneration: UInt64) { guard routeGeneration == self.routeGeneration, retireSocketGeneration(socketGeneration) else { return } self.lastSnapshot = nil } } extension GatewayConnection { private func admitSocketGeneration(_ socketGeneration: UInt64) -> Bool { if let lastRetiredSocketGeneration, socketGeneration <= lastRetiredSocketGeneration { return false } if let activeSocketGeneration { return socketGeneration == activeSocketGeneration } activeSocketGeneration = socketGeneration return true } private func retireSocketGeneration(_ socketGeneration: UInt64) -> Bool { if let lastRetiredSocketGeneration, socketGeneration <= lastRetiredSocketGeneration { return false } if let activeSocketGeneration, socketGeneration != activeSocketGeneration { return false } activeSocketGeneration = nil lastRetiredSocketGeneration = socketGeneration return true } private func resetSocketGeneration() { self.activeSocketGeneration = nil self.lastRetiredSocketGeneration = nil } #if DEBUG func _test_routeGeneration() -> UInt64 { self.routeGeneration } func _test_configuredURL() -> URL? { self.configuredURL } func _test_handlePush( _ push: GatewayPush, routeGeneration: UInt64? = nil, socketGeneration: UInt64) { self.handle( push: push, routeGeneration: routeGeneration ?? self.routeGeneration, socketGeneration: socketGeneration) } func _test_handleDisconnect( routeGeneration: UInt64? = nil, socketGeneration: UInt64) { self.handleDisconnect( routeGeneration: routeGeneration ?? self.routeGeneration, socketGeneration: socketGeneration) } #endif private static func defaultEndpointProvider() async throws -> EndpointSnapshot { try await GatewayEndpointStore.shared.requireEndpoint() } private func currentEndpoint() async throws -> EndpointSnapshot { let endpoint = try await endpointProvider() try acceptEndpointRevision(endpoint) return endpoint } private func acceptEndpointRevision(_ endpoint: EndpointSnapshot) throws { guard let revision = endpoint.revision else { return } if let highestEndpointRevision, revision < highestEndpointRevision { throw CancellationError() } if highestEndpointRevision.map({ revision > $0 }) ?? true { highestEndpointRevision = revision } } static func defaultActivationBindingKey() -> SymmetricKey? { GatewayActivationBindingKeyStore.loadOrCreate() } private static func activationOwnershipFingerprint( config: Config, authBinding: GatewayAuthBinding? = nil, key: SymmetricKey?) -> String? { guard let key else { return nil } // The durable record is already keyed by the stable Gateway route identity. // Bind only auth here so an SSH tunnel's ephemeral local URL can rebind safely. var values = [config.token ?? "", config.password ?? ""] if authBinding?.source == .deviceToken { guard let credentialFingerprint = authBinding?.credentialFingerprint else { return nil } values.append(contentsOf: [GatewayAuthSource.deviceToken.rawValue, credentialFingerprint]) } let framed = values.map { "\($0.utf8.count):\($0)" }.joined(separator: "|") let tag = HMAC.authenticationCode(for: Data(framed.utf8), using: key) return tag.map { String(format: "%02x", $0) }.joined() } } // MARK: - Snapshot cache and subscriptions extension GatewayConnection { func canvasPluginSurfaceUrl() async -> String? { guard let snapshot = lastSnapshot else { return nil } let raw = snapshot.pluginsurfaceurls?["canvas"]?.value as? String let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } func controlUiAutoAuthToken(config: Config) async -> String? { guard let endpoint = try? await currentEndpoint(), endpoint.config.url == config.url, endpoint.config.token == config.token, endpoint.config.password == config.password, let client, let socketGeneration = activeSocketGeneration, controlUiRouteIsLive( config: config, routeAuthority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, client: client, socketGeneration: socketGeneration), await client.currentConnectionGeneration() == socketGeneration, controlUiRouteIsLive( config: config, routeAuthority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, client: client, socketGeneration: socketGeneration), let authBinding = await client.authBinding( ifCurrentConnectionGeneration: socketGeneration), controlUiRouteIsLive( config: config, routeAuthority: endpoint.routeAuthority, deviceAuthGatewayID: endpoint.deviceAuthGatewayID, client: client, socketGeneration: socketGeneration) else { return nil } switch authBinding.source { case .sharedToken: return config.token?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty case .deviceToken: guard let gatewayID = configuredDeviceAuthGatewayID? .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty else { return nil } if let deviceToken = lastSnapshot?.auth["deviceToken"]?.value as? String, let token = deviceToken.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty { return token } let identity = DeviceIdentityStore.loadOrCreate() return DeviceAuthStore.loadToken( deviceId: identity.deviceId, role: "operator", gatewayID: gatewayID)?.token .trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty case .bootstrapToken, .password, .none: return nil } } private func controlUiRouteIsLive( config: Config, routeAuthority: UInt64?, deviceAuthGatewayID: String?, client: GatewayChannelActor, socketGeneration: UInt64) -> Bool { self.configuredURL == config.url && self.configuredToken == config.token && self.configuredPassword == config.password && self.configuredRouteAuthority == routeAuthority && self.configuredDeviceAuthGatewayID == deviceAuthGatewayID && self.configuredShutdownGeneration == self.shutdownGeneration && self.client === client && self.activeSocketGeneration == socketGeneration && self.lastSnapshot != nil } private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String { let raw = defaults?[key]?.value as? String return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) } func cachedMainSessionKey() -> String? { guard let snapshot = lastSnapshot else { return nil } let trimmed = self.sessionDefaultString(snapshot.snapshot.sessiondefaults, key: "mainSessionKey") return trimmed.isEmpty ? nil : trimmed } func cachedGatewayVersion() -> String? { guard let snapshot = lastSnapshot else { return nil } let raw = snapshot.server["version"]?.value as? String let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } func snapshotPaths() -> (configPath: String?, stateDir: String?) { guard let snapshot = lastSnapshot else { return (nil, nil) } let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) return ( configPath?.isEmpty == false ? configPath : nil, stateDir?.isEmpty == false ? stateDir : nil) } func subscribe(bufferingNewest: Int = 100) -> AsyncStream { let id = UUID() let snapshot = self.lastSnapshot let connection = self return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in if let snapshot { continuation.yield(.snapshot(snapshot)) } self.subscribers[id] = continuation continuation.onTermination = { @Sendable _ in Task { await connection.removeSubscriber(id) } } } } private func removeSubscriber(_ id: UUID) { self.subscribers[id] = nil } private func broadcast(_ push: GatewayPush) { if case let .snapshot(snapshot) = push { self.lastSnapshot = snapshot if let mainSessionKey = cachedMainSessionKey() { Task { @MainActor in WorkActivityStore.shared.setMainSessionKey(mainSessionKey) } } } for (_, continuation) in self.subscribers { continuation.yield(push) } } private func canonicalizeSessionKey(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } guard let defaults = lastSnapshot?.snapshot.sessiondefaults else { return trimmed } let mainSessionKey = self.sessionDefaultString(defaults, key: "mainSessionKey") guard !mainSessionKey.isEmpty else { return trimmed } let mainKey = self.sessionDefaultString(defaults, key: "mainKey") let defaultAgentId = self.sessionDefaultString(defaults, key: "defaultAgentId") let isMainAlias = trimmed == "main" || (!mainKey.isEmpty && trimmed == mainKey) || trimmed == mainSessionKey || (!defaultAgentId.isEmpty && (trimmed == "agent:\(defaultAgentId):main" || (mainKey.isEmpty == false && trimmed == "agent:\(defaultAgentId):\(mainKey)"))) return isMainAlias ? mainSessionKey : trimmed } } // MARK: - Typed gateway API extension GatewayConnection { struct ConfigGetSnapshot: Decodable { struct SnapshotConfig: Decodable { struct Session: Decodable { let mainKey: String? let scope: String? } let session: Session? } let config: SnapshotConfig? } static func mainSessionKey(fromConfigGetData data: Data) throws -> String { let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines) if scope == "global" { return "global" } return "main" } func mainSessionKey(timeoutMs: Double = 15000) async -> String { if let cached = cachedMainSessionKey() { return cached } do { let data = try await requestRaw(method: "config.get", params: nil, timeoutMs: timeoutMs) return try Self.mainSessionKey(fromConfigGetData: data) } catch { return "main" } } func status() async -> (ok: Bool, error: String?) { do { _ = try await self.requestRaw(method: .status) return (true, nil) } catch { return (false, error.localizedDescription) } } func setHeartbeatsEnabled(_ enabled: Bool) async -> Bool { do { try await self.requestVoid(method: .setHeartbeats, params: ["enabled": AnyCodable(enabled)]) return true } catch { gatewayConnectionLogger.error("setHeartbeatsEnabled failed \(error.localizedDescription, privacy: .public)") return false } } func sendAgent(_ invocation: GatewayAgentInvocation) async -> (ok: Bool, error: String?) { let trimmed = invocation.message.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return (false, "message empty") } let sessionKey = self.canonicalizeSessionKey(invocation.sessionKey) var params: [String: AnyCodable] = [ "message": AnyCodable(trimmed), "sessionKey": AnyCodable(sessionKey), "deliver": AnyCodable(invocation.deliver), "to": AnyCodable(invocation.to ?? ""), "channel": AnyCodable(invocation.channel.rawValue), "idempotencyKey": AnyCodable(invocation.idempotencyKey), ] if let thinking = invocation.thinking?.trimmingCharacters(in: .whitespacesAndNewlines), !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) } if let timeout = invocation.timeoutSeconds { params["timeout"] = AnyCodable(timeout) } if let trigger = invocation.voiceWakeTrigger { params["voiceWakeTrigger"] = AnyCodable( trigger.trimmingCharacters(in: .whitespacesAndNewlines)) } do { try await self.requestVoid(method: .agent, params: params) return (true, nil) } catch { return (false, error.localizedDescription) } } func sendAgent( message: String, thinking: String?, sessionKey: String, deliver: Bool, to: String?, channel: GatewayAgentChannel = .last, timeoutSeconds: Int? = nil, idempotencyKey: String = UUID().uuidString) async -> (ok: Bool, error: String?) { await self.sendAgent(GatewayAgentInvocation( message: message, sessionKey: sessionKey, thinking: thinking, deliver: deliver, to: to, channel: channel, timeoutSeconds: timeoutSeconds, idempotencyKey: idempotencyKey)) } // MARK: - Health func healthSnapshot(timeoutMs: Double? = nil) async throws -> HealthSnapshot { let data = try await requestRaw(method: .health, timeoutMs: timeoutMs) if let snap = decodeHealthSnapshot(from: data) { return snap } throw GatewayDecodingError(method: Method.health.rawValue, message: "failed to decode health snapshot") } func healthOK(timeoutMs: Int = 8000) async throws -> Bool { let data = try await requestRaw(method: .health, timeoutMs: Double(timeoutMs)) return (try? self.decoder.decode(OpenClawGatewayHealthOK.self, from: data))?.ok ?? true } // MARK: - Skills func skillsStatus() async throws -> SkillsStatusReport { try await self.requestDecoded(method: .skillsStatus) } func skillsInstall( name: String, installId: String, dangerouslyForceUnsafeInstall: Bool? = nil, timeoutMs: Int? = nil) async throws -> SkillInstallResult { var params: [String: AnyCodable] = [ "name": AnyCodable(name), "installId": AnyCodable(installId), ] if let dangerouslyForceUnsafeInstall { params["dangerouslyForceUnsafeInstall"] = AnyCodable(dangerouslyForceUnsafeInstall) } if let timeoutMs { params["timeoutMs"] = AnyCodable(timeoutMs) } return try await self.requestDecoded(method: .skillsInstall, params: params) } func skillsUpdate( skillKey: String, enabled: Bool? = nil, apiKey: String? = nil, env: [String: String]? = nil) async throws -> SkillUpdateResult { var params: [String: AnyCodable] = [ "skillKey": AnyCodable(skillKey), ] if let enabled { params["enabled"] = AnyCodable(enabled) } if let apiKey { params["apiKey"] = AnyCodable(apiKey) } if let env, !env.isEmpty { params["env"] = AnyCodable(env) } return try await self.requestDecoded(method: .skillsUpdate, params: params) } // MARK: - Sessions func sessionsPreview( keys: [String], limit: Int? = nil, maxChars: Int? = nil, timeoutMs: Int? = nil) async throws -> OpenClawSessionsPreviewPayload { let resolvedKeys = keys .map { self.canonicalizeSessionKey($0) } .filter { !$0.isEmpty } if resolvedKeys.isEmpty { return OpenClawSessionsPreviewPayload(ts: 0, previews: []) } var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)] if let limit { params["limit"] = AnyCodable(limit) } if let maxChars { params["maxChars"] = AnyCodable(maxChars) } let timeout = timeoutMs.map { Double($0) } return try await self.requestDecoded( method: .sessionsPreview, params: params, timeoutMs: timeout) } // MARK: - Chat func chatHistory( sessionKey: String, agentID: String? = nil, limit: Int? = nil, maxChars: Int? = nil, timeoutMs: Int? = nil, ifCurrentRoute route: Route? = nil) async throws -> OpenClawChatHistoryPayload { let resolvedKey = self.canonicalizeSessionKey(sessionKey) var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty { params["agentId"] = AnyCodable(agentID) } if let limit { params["limit"] = AnyCodable(limit) } if let maxChars { params["maxChars"] = AnyCodable(maxChars) } let timeout = timeoutMs.map { Double($0) } if let route { let data = try await request( method: Method.chatHistory.rawValue, params: params, timeoutMs: timeout, ifCurrentRoute: route) return try self.decoder.decode(OpenClawChatHistoryPayload.self, from: data) } return try await self.requestDecoded(method: .chatHistory, params: params, timeoutMs: timeout) } func chatSend( sessionKey: String, agentID: String? = nil, expectedSessionRoutingContract: String? = nil, message: String, thinking: String?, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload], runTimeoutMs: Int? = nil, requestTimeoutMs: Int = 30000, ifCurrentRoute route: Route? = nil, distinguishPreDispatchRouteChange: Bool = false) async throws -> OpenClawChatSendResponse { let resolvedKey = self.canonicalizeSessionKey(sessionKey) var params: [String: AnyCodable] = [ "sessionKey": AnyCodable(resolvedKey), "message": AnyCodable(message), "idempotencyKey": AnyCodable(idempotencyKey), ] if let runTimeoutMs { params["timeoutMs"] = AnyCodable(runTimeoutMs) } if let agentID = agentID?.trimmingCharacters(in: .whitespacesAndNewlines), !agentID.isEmpty { params["agentId"] = AnyCodable(agentID) } if let expectedSessionRoutingContract = expectedSessionRoutingContract? .trimmingCharacters(in: .whitespacesAndNewlines), !expectedSessionRoutingContract.isEmpty { params["expectedSessionRoutingContract"] = AnyCodable(expectedSessionRoutingContract) } if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines), !thinking.isEmpty { params["thinking"] = AnyCodable(thinking) } if !attachments.isEmpty { let encoded = attachments.map { att in [ "type": att.type, "mimeType": att.mimeType, "fileName": att.fileName, "content": att.content, ] } params["attachments"] = AnyCodable(encoded) } if let route { let data = try await request( method: Method.chatSend.rawValue, params: params, timeoutMs: Double(requestTimeoutMs), ifCurrentRoute: route, distinguishPreDispatchRouteChange: distinguishPreDispatchRouteChange) return try self.decoder.decode(OpenClawChatSendResponse.self, from: data) } return try await self.requestDecoded( method: .chatSend, params: params, timeoutMs: Double(requestTimeoutMs)) } func talkMode(enabled: Bool, phase: String? = nil) async { var params: [String: AnyCodable] = ["enabled": AnyCodable(enabled)] if let phase { params["phase"] = AnyCodable(phase) } try? await self.requestVoid(method: .talkMode, params: params) } // MARK: - VoiceWake func voiceWakeGetTriggers() async throws -> [String] { struct VoiceWakePayload: Decodable { let triggers: [String] } let payload: VoiceWakePayload = try await requestDecoded(method: .voicewakeGet) return payload.triggers } func voiceWakeSetTriggers(_ triggers: [String]) async { do { try await self.requestVoid( method: .voicewakeSet, params: ["triggers": AnyCodable(triggers)], timeoutMs: 10000) } catch { // Best-effort only. } } // MARK: - Node pairing func nodePairApprove(requestId: String) async throws { try await self.requestVoid( method: .nodePairApprove, params: ["requestId": AnyCodable(requestId)], timeoutMs: 10000) } func nodePairReject(requestId: String) async throws { try await self.requestVoid( method: .nodePairReject, params: ["requestId": AnyCodable(requestId)], timeoutMs: 10000) } // MARK: - Device pairing func devicePairApprove(requestId: String) async throws { try await self.requestVoid( method: .devicePairApprove, params: ["requestId": AnyCodable(requestId)], timeoutMs: 10000) } func devicePairReject(requestId: String) async throws { try await self.requestVoid( method: .devicePairReject, params: ["requestId": AnyCodable(requestId)], timeoutMs: 10000) } // MARK: - Cron struct CronSchedulerStatus: Decodable { let enabled: Bool let storePath: String let sqlitePath: String? let jobs: Int let nextWakeAtMs: Int? } func cronStatus() async throws -> CronSchedulerStatus { try await self.requestDecoded(method: .cronStatus) } func cronList(includeDisabled: Bool = true) async throws -> [CronJob] { let data = try await requestRaw( method: .cronList, params: ["includeDisabled": AnyCodable(includeDisabled)]) return try Self.decodeCronListResponse(data) } func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] { let data = try await requestRaw( method: .cronRuns, params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)]) return try Self.decodeCronRunsResponse(data) } func cronRun(jobId: String, force: Bool = true) async throws { try await self.requestVoid( method: .cronRun, params: [ "id": AnyCodable(jobId), "mode": AnyCodable(force ? "force" : "due"), ], timeoutMs: 20000) } func cronRemove(jobId: String) async throws { try await self.requestVoid(method: .cronRemove, params: ["id": AnyCodable(jobId)]) } func cronUpdate(jobId: String, patch: [String: AnyCodable]) async throws { try await self.requestVoid( method: .cronUpdate, params: ["id": AnyCodable(jobId), "patch": AnyCodable(patch)]) } func cronAdd(payload: [String: AnyCodable]) async throws { try await self.requestVoid(method: .cronAdd, params: payload) } nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] { let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data) let jobs = decoded.jobs.compactMap(\.value) let skipped = decoded.jobs.count - jobs.count if skipped > 0 { gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs") } return jobs } nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] { let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data) let entries = decoded.entries.compactMap(\.value) let skipped = decoded.entries.count - entries.count if skipped > 0 { gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries") } return entries } }