From bdba90a20b2b05c0100e5eabc5b7a86af8053a9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 08:10:35 +0100 Subject: [PATCH] feat: add authenticated iOS background presence beacon (#73330) * feat: add iOS background presence beacon Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> * fix: keep iOS background reconnects ahead of beacon throttle * build: refresh gateway protocol swift models * fix: emit swift protocol string enums --------- Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> --- CHANGELOG.md | 1 + apps/ios/Sources/Model/NodeAppModel.swift | 126 +++++++++++--- .../Sources/Push/BackgroundAliveBeacon.swift | 92 ++++++++++ apps/ios/SwiftSources.input.xcfilelist | 1 + .../Tests/BackgroundAliveBeaconTests.swift | 74 ++++++++ .../OpenClawProtocol/GatewayModels.swift | 77 +++++++++ .../OpenClawProtocol/GatewayModels.swift | 77 +++++++++ docs/gateway/pairing.md | 5 + docs/gateway/protocol.md | 34 ++++ docs/platforms/ios.md | 11 ++ scripts/protocol-gen-swift.ts | 24 ++- src/gateway/node-catalog.test.ts | 54 ++++++ src/gateway/node-catalog.ts | 40 +++++ src/gateway/protocol/index.test.ts | 42 +++++ src/gateway/protocol/index.ts | 16 ++ src/gateway/protocol/schema/nodes.ts | 35 ++++ .../protocol/schema/protocol-schemas.ts | 6 + src/gateway/protocol/schema/types.ts | 3 + src/gateway/server-methods/nodes.ts | 15 +- src/gateway/server-node-events.test.ts | 153 +++++++++++++++- src/gateway/server-node-events.ts | 163 ++++++++++++++---- src/infra/device-pairing.test.ts | 27 +++ src/infra/device-pairing.ts | 15 +- src/infra/node-pairing.test.ts | 28 +++ src/infra/node-pairing.ts | 11 +- src/shared/node-list-types.ts | 4 + src/shared/node-presence.ts | 24 +++ 27 files changed, 1082 insertions(+), 76 deletions(-) create mode 100644 apps/ios/Sources/Push/BackgroundAliveBeacon.swift create mode 100644 apps/ios/Tests/BackgroundAliveBeaconTests.swift create mode 100644 src/shared/node-presence.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe149e57a3..c3a8b866fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman. - Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong. - Security/networking: add opt-in operator-managed outbound proxy routing (proxy.enabled + proxy.proxyUrl/OPENCLAW_PROXY_URL) with strict http:// forward-proxy validation, loopback-only Gateway bypass, and cleanup of proxy env/dispatcher state on exit. (#70044) Thanks @jesse-merhi and @joshavant. diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 984e6288733..60748dacc33 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -200,6 +200,8 @@ final class NodeAppModel { private(set) var activeGatewayConnectConfig: GatewayConnectConfig? private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1" + private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs" + private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger" var cameraHUDText: String? var cameraHUDKind: CameraHUDKind? @@ -3142,32 +3144,39 @@ extension NodeAppModel { return handled } - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + let result = await self.performBackgroundAliveBeaconIfNeeded( + wakeId: wakeId, + trigger: .silentPush) let outcomeMessage = "Silent push outcome wakeId=\(wakeId) " + "applied=\(result.applied) " + + "handled=\(result.handled) " + "reason=\(result.reason) " + "durationMs=\(result.durationMs)" self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)") - return result.applied + return result.handled } func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool { let wakeId = Self.makePushWakeAttemptID() + let normalizedTrigger = BackgroundAliveBeacon.normalizeTrigger(trigger) let receivedMessage = "Background refresh wake received wakeId=\(wakeId) " - + "trigger=\(trigger) " + + "trigger=\(normalizedTrigger.rawValue) " + "backgrounded=\(self.isBackgrounded) " + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" self.pushWakeLogger.info("\(receivedMessage, privacy: .public)") - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + let result = await self.performBackgroundAliveBeaconIfNeeded( + wakeId: wakeId, + trigger: normalizedTrigger) let outcomeMessage = "Background refresh wake outcome wakeId=\(wakeId) " + "applied=\(result.applied) " + + "handled=\(result.handled) " + "reason=\(result.reason) " + "durationMs=\(result.durationMs)" self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)") - return result.applied + return result.handled } func handleSignificantLocationWakeIfNeeded() async { @@ -3196,10 +3205,13 @@ extension NodeAppModel { + "backgrounded=\(self.isBackgrounded) " + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" self.locationWakeLogger.info("\(beginMessage, privacy: .public)") - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + let result = await self.performBackgroundAliveBeaconIfNeeded( + wakeId: wakeId, + trigger: .significantLocation) let triggerMessage = "Location wake trigger wakeId=\(wakeId) " + "applied=\(result.applied) " + + "handled=\(result.handled) " + "reason=\(result.reason) " + "durationMs=\(result.durationMs)" self.locationWakeLogger.info("\(triggerMessage, privacy: .public)") @@ -3621,8 +3633,9 @@ extension NodeAppModel { return gatewayError.message.lowercased().contains("allow-always is unavailable") } - private struct SilentPushWakeAttemptResult { + private struct BackgroundAliveWakeAttemptResult { var applied: Bool + var handled: Bool var reason: String var durationMs: Int } @@ -3797,43 +3810,100 @@ extension NodeAppModel { return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250) } - private func reconnectGatewaySessionsForSilentPushIfNeeded( - wakeId: String) async -> SilentPushWakeAttemptResult + private func performBackgroundAliveBeaconIfNeeded( + wakeId: String, + trigger: BackgroundAliveBeacon.Trigger) async -> BackgroundAliveWakeAttemptResult { let startedAt = Date() - let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in + let makeResult: (Bool, Bool, String) -> BackgroundAliveWakeAttemptResult = { applied, handled, reason in let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) - return SilentPushWakeAttemptResult( + return BackgroundAliveWakeAttemptResult( applied: applied, + handled: handled, reason: reason, durationMs: max(0, durationMs)) } guard self.isBackgrounded else { self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded") - return makeResult(false, "not_backgrounded") + return makeResult(false, false, "not_backgrounded") } guard self.gatewayAutoReconnectEnabled else { self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled") - return makeResult(false, "auto_reconnect_disabled") + return makeResult(false, false, "auto_reconnect_disabled") } - guard let cfg = self.activeGatewayConnectConfig else { - self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config") - return makeResult(false, "no_active_gateway_config") + let now = Date() + let gatewayConnected = await self.isGatewayConnected() + + var appliedReconnect = false + if !gatewayConnected { + guard let cfg = self.activeGatewayConnectConfig else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config") + return makeResult(false, false, "no_active_gateway_config") + } + self.pushWakeLogger.info( + "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") + self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + self.operatorConnected = false + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" + self.talkMode.updateGatewayConnected(false) + self.applyGatewayConnectConfig(cfg) + appliedReconnect = true + self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") + + let connected = await self.waitForGatewayConnection(timeoutMs: 12000, pollMs: 250) + guard connected else { + return makeResult(appliedReconnect, false, "connect_timeout") + } + } else if BackgroundAliveBeacon.shouldSkipRecentSuccess( + isGatewayConnected: true, + now: now, + lastSuccessAtMs: UserDefaults.standard.object(forKey: Self.backgroundAliveLastSuccessAtMsKey) as? Double) + { + return makeResult(false, true, "recent_success") } - self.pushWakeLogger.info( - "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") - self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - self.operatorConnected = false - self.gatewayConnected = false - self.gatewayStatusText = "Reconnecting…" - self.talkMode.updateGatewayConnected(false) - self.applyGatewayConnectConfig(cfg) - self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") - return makeResult(true, "reconnect_triggered") + let beacon = await self.publishBackgroundAliveBeacon(trigger: trigger) + if beacon.handled { + let successAtMs = Date().timeIntervalSince1970 * 1000 + UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey) + UserDefaults.standard.set(trigger.rawValue, forKey: Self.backgroundAliveLastTriggerKey) + return makeResult(appliedReconnect, true, beacon.reason) + } + return makeResult(appliedReconnect, false, beacon.reason) + } + + private func publishBackgroundAliveBeacon( + trigger: BackgroundAliveBeacon.Trigger) async -> (handled: Bool, reason: String) + { + do { + let pushTransport = await self.pushRegistrationManager.usesRelayTransport ? "relay" : "direct" + let displayName = NodeDisplayName.resolve( + existing: UserDefaults.standard.string(forKey: "node.displayName"), + deviceName: UIDevice.current.name, + interfaceIdiom: UIDevice.current.userInterfaceIdiom) + let payload = BackgroundAliveBeacon.makePayload( + trigger: trigger, + displayName: displayName, + pushTransport: pushTransport) + let paramsJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload) + let response = try await self.nodeGateway.request( + method: "node.event", + paramsJSON: paramsJSON, + timeoutSeconds: 8) + guard let decoded = BackgroundAliveBeacon.decodeResponse(response) else { + return (false, "invalid_response") + } + if decoded.handled == true { + return (true, decoded.reason ?? "beacon_persisted") + } + return (false, decoded.reason ?? "unsupported") + } catch { + return (false, "beacon_failed") + } } } diff --git a/apps/ios/Sources/Push/BackgroundAliveBeacon.swift b/apps/ios/Sources/Push/BackgroundAliveBeacon.swift new file mode 100644 index 00000000000..5ae8edddd93 --- /dev/null +++ b/apps/ios/Sources/Push/BackgroundAliveBeacon.swift @@ -0,0 +1,92 @@ +import Foundation +import UIKit + +enum BackgroundAliveBeacon { + static let eventName = "node.presence.alive" + static let minSuccessIntervalSeconds: TimeInterval = 10 * 60 + + enum Trigger: String, CaseIterable, Codable { + case background + case silentPush = "silent_push" + case bgAppRefresh = "bg_app_refresh" + case significantLocation = "significant_location" + case manual + case connect + } + + struct Payload: Encodable { + var trigger: String + var sentAtMs: Int64 + var displayName: String + var version: String + var platform: String + var deviceFamily: String + var modelIdentifier: String + var pushTransport: String? + } + + struct NodeEventRequestPayload: Codable { + var event: String = BackgroundAliveBeacon.eventName + var payloadJSON: String + } + + struct NodeEventResponsePayload: Decodable { + var ok: Bool? + var event: String? + var handled: Bool? + var reason: String? + } + + static func normalizeTrigger(_ raw: String) -> Trigger { + let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return Trigger(rawValue: normalized) ?? .background + } + + static func shouldSkipRecentSuccess( + isGatewayConnected: Bool, + now: Date, + lastSuccessAtMs: Double?, + minInterval: TimeInterval = Self.minSuccessIntervalSeconds) -> Bool + { + guard isGatewayConnected else { return false } + guard let lastSuccessAtMs, lastSuccessAtMs > 0 else { return false } + let elapsed = now.timeIntervalSince1970 - (lastSuccessAtMs / 1000.0) + return elapsed >= 0 && elapsed < minInterval + } + + @MainActor + static func makePayload(trigger: Trigger, displayName: String, pushTransport: String?) -> Payload { + Payload( + trigger: trigger.rawValue, + sentAtMs: Int64(Date().timeIntervalSince1970 * 1000), + displayName: displayName, + version: DeviceInfoHelper.appVersion(), + platform: DeviceInfoHelper.platformString(), + deviceFamily: DeviceInfoHelper.deviceFamily(), + modelIdentifier: DeviceInfoHelper.modelIdentifier(), + pushTransport: pushTransport) + } + + static func makeNodeEventRequestPayloadJSON( + payload: Payload, + encoder: JSONEncoder = JSONEncoder()) throws -> String + { + let payloadData = try encoder.encode(payload) + guard let payloadJSON = String(data: payloadData, encoding: .utf8) else { + throw EncodingError.invalidValue(payload, EncodingError.Context( + codingPath: [], + debugDescription: "Failed to encode background alive payload as UTF-8")) + } + let requestData = try encoder.encode(NodeEventRequestPayload(payloadJSON: payloadJSON)) + guard let requestJSON = String(data: requestData, encoding: .utf8) else { + throw EncodingError.invalidValue(payload, EncodingError.Context( + codingPath: [], + debugDescription: "Failed to encode node.event payload as UTF-8")) + } + return requestJSON + } + + static func decodeResponse(_ data: Data) -> NodeEventResponsePayload? { + try? JSONDecoder().decode(NodeEventResponsePayload.self, from: data) + } +} diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index b9808a27743..8ec9dc1c767 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -42,6 +42,7 @@ Sources/Onboarding/OnboardingWizardView.swift Sources/Onboarding/QRScannerView.swift Sources/OpenClawApp.swift Sources/Push/ExecApprovalNotificationBridge.swift +Sources/Push/BackgroundAliveBeacon.swift Sources/Push/PushBuildConfig.swift Sources/Push/PushRegistrationManager.swift Sources/Push/PushRelayClient.swift diff --git a/apps/ios/Tests/BackgroundAliveBeaconTests.swift b/apps/ios/Tests/BackgroundAliveBeaconTests.swift new file mode 100644 index 00000000000..1dc888d2b53 --- /dev/null +++ b/apps/ios/Tests/BackgroundAliveBeaconTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct BackgroundAliveBeaconTests { + @Test func `normalize trigger accepts closed reasons`() { + #expect(BackgroundAliveBeacon.normalizeTrigger("silent_push") == .silentPush) + #expect(BackgroundAliveBeacon.normalizeTrigger(" bg_app_refresh ") == .bgAppRefresh) + #expect(BackgroundAliveBeacon.normalizeTrigger("SIGNIFICANT_LOCATION") == .significantLocation) + } + + @Test func `normalize trigger falls back to background`() { + #expect(BackgroundAliveBeacon.normalizeTrigger("watch_prompt_action") == .background) + #expect(BackgroundAliveBeacon.normalizeTrigger("") == .background) + } + + @Test func `recent success throttle uses milliseconds`() { + let now = Date(timeIntervalSince1970: 1000) + + #expect(BackgroundAliveBeacon.shouldSkipRecentSuccess( + isGatewayConnected: true, + now: now, + lastSuccessAtMs: 999_500, + minInterval: 10)) + #expect(!BackgroundAliveBeacon.shouldSkipRecentSuccess( + isGatewayConnected: true, + now: now, + lastSuccessAtMs: 980_000, + minInterval: 10)) + } + + @Test func `recent success throttle does not suppress disconnected wakes`() { + let now = Date(timeIntervalSince1970: 1000) + + #expect(!BackgroundAliveBeacon.shouldSkipRecentSuccess( + isGatewayConnected: false, + now: now, + lastSuccessAtMs: 999_500, + minInterval: 10)) + } + + @Test func `make node event payload wraps presence payload JSON`() throws { + let payload = BackgroundAliveBeacon.Payload( + trigger: BackgroundAliveBeacon.Trigger.silentPush.rawValue, + sentAtMs: 123, + displayName: "Peter's iPhone", + version: "2026.4.28", + platform: "iOS 18.4.0", + deviceFamily: "iPhone", + modelIdentifier: "iPhone17,1", + pushTransport: "relay") + let requestJSON = try BackgroundAliveBeacon.makeNodeEventRequestPayloadJSON(payload: payload) + let requestData = try #require(requestJSON.data(using: .utf8)) + let request = try JSONDecoder().decode( + BackgroundAliveBeacon.NodeEventRequestPayload.self, + from: requestData) + + #expect(request.event == "node.presence.alive") + let payloadData = try #require(request.payloadJSON.data(using: .utf8)) + let decodedPayload = try #require(JSONSerialization.jsonObject(with: payloadData) as? [String: Any]) + let sentAtMs = try #require(decodedPayload["sentAtMs"] as? Int) + #expect(decodedPayload["trigger"] as? String == "silent_push") + #expect(sentAtMs == 123) + #expect(decodedPayload["pushTransport"] as? String == "relay") + } + + @Test func `old gateway ack does not count as handled`() throws { + let data = try #require(#"{"ok":true}"#.data(using: .utf8)) + let response = try #require(BackgroundAliveBeacon.decodeResponse(data)) + + #expect(response.ok == true) + #expect(response.handled == nil) + } +} diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 504737e23e4..c22e963bcae 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -13,6 +13,15 @@ public enum ErrorCode: String, Codable, Sendable { case unavailable = "UNAVAILABLE" } +public enum NodePresenceAliveReason: String, Codable, Sendable { + case background = "background" + case silentPush = "silent_push" + case bgAppRefresh = "bg_app_refresh" + case significantLocation = "significant_location" + case manual = "manual" + case connect = "connect" +} + public struct ConnectParams: Codable, Sendable { public let minprotocol: Int public let maxprotocol: Int @@ -1063,6 +1072,74 @@ public struct NodeEventParams: Codable, Sendable { } } +public struct NodeEventResult: Codable, Sendable { + public let ok: Bool + public let event: String + public let handled: Bool + public let reason: String? + + public init( + ok: Bool, + event: String, + handled: Bool, + reason: String?) + { + self.ok = ok + self.event = event + self.handled = handled + self.reason = reason + } + + private enum CodingKeys: String, CodingKey { + case ok + case event + case handled + case reason + } +} + +public struct NodePresenceAlivePayload: Codable, Sendable { + public let trigger: NodePresenceAliveReason + public let sentatms: Int? + public let displayname: String? + public let version: String? + public let platform: String? + public let devicefamily: String? + public let modelidentifier: String? + public let pushtransport: String? + + public init( + trigger: NodePresenceAliveReason, + sentatms: Int?, + displayname: String?, + version: String?, + platform: String?, + devicefamily: String?, + modelidentifier: String?, + pushtransport: String?) + { + self.trigger = trigger + self.sentatms = sentatms + self.displayname = displayname + self.version = version + self.platform = platform + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.pushtransport = pushtransport + } + + private enum CodingKeys: String, CodingKey { + case trigger + case sentatms = "sentAtMs" + case displayname = "displayName" + case version + case platform + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case pushtransport = "pushTransport" + } +} + public struct NodePendingDrainParams: Codable, Sendable { public let maxitems: Int? diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 504737e23e4..c22e963bcae 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -13,6 +13,15 @@ public enum ErrorCode: String, Codable, Sendable { case unavailable = "UNAVAILABLE" } +public enum NodePresenceAliveReason: String, Codable, Sendable { + case background = "background" + case silentPush = "silent_push" + case bgAppRefresh = "bg_app_refresh" + case significantLocation = "significant_location" + case manual = "manual" + case connect = "connect" +} + public struct ConnectParams: Codable, Sendable { public let minprotocol: Int public let maxprotocol: Int @@ -1063,6 +1072,74 @@ public struct NodeEventParams: Codable, Sendable { } } +public struct NodeEventResult: Codable, Sendable { + public let ok: Bool + public let event: String + public let handled: Bool + public let reason: String? + + public init( + ok: Bool, + event: String, + handled: Bool, + reason: String?) + { + self.ok = ok + self.event = event + self.handled = handled + self.reason = reason + } + + private enum CodingKeys: String, CodingKey { + case ok + case event + case handled + case reason + } +} + +public struct NodePresenceAlivePayload: Codable, Sendable { + public let trigger: NodePresenceAliveReason + public let sentatms: Int? + public let displayname: String? + public let version: String? + public let platform: String? + public let devicefamily: String? + public let modelidentifier: String? + public let pushtransport: String? + + public init( + trigger: NodePresenceAliveReason, + sentatms: Int?, + displayname: String?, + version: String?, + platform: String?, + devicefamily: String?, + modelidentifier: String?, + pushtransport: String?) + { + self.trigger = trigger + self.sentatms = sentatms + self.displayname = displayname + self.version = version + self.platform = platform + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier + self.pushtransport = pushtransport + } + + private enum CodingKeys: String, CodingKey { + case trigger + case sentatms = "sentAtMs" + case displayname = "displayName" + case version + case platform + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" + case pushtransport = "pushTransport" + } +} + public struct NodePendingDrainParams: Codable, Sendable { public let maxitems: Int? diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index f15e8195c5b..0d52f1f27fa 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -105,6 +105,11 @@ This means: Node-originated summaries and related session events are restricted to the intended trusted surface. Notification-driven or node-triggered flows that previously relied on broader host or session tool access may need adjustment. This hardening ensures that node events cannot escalate into host-level tool access beyond what the node's trust boundary permits. +Durable node presence updates follow the same identity boundary. The `node.presence.alive` event is +accepted only from authenticated node device sessions and updates pairing metadata only when the +device/node identity is already paired. Self-declared `client.id` values are not enough to write +last-seen state. + ## Auto-approval (macOS app) The macOS app can optionally attempt a **silent approval** when: diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index e9cae71fcef..1e06804cf76 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -255,6 +255,40 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - `system-presence` returns entries keyed by device identity. - Presence entries include `deviceId`, `roles`, and `scopes` so UIs can show a single row per device even when it connects as both **operator** and **node**. +- `node.list` includes optional `lastSeenAtMs` and `lastSeenReason` fields. Connected nodes report + their current connection time as `lastSeenAtMs` with reason `connect`; paired nodes can also report + durable background presence when a trusted node event updates their pairing metadata. + +### Node background alive event + +Nodes may call `node.event` with `event: "node.presence.alive"` to record that a paired node was +alive during a background wake without marking it connected. + +```json +{ + "event": "node.presence.alive", + "payloadJSON": "{\"trigger\":\"silent_push\",\"sentAtMs\":1737264000000,\"displayName\":\"Peter's iPhone\",\"version\":\"2026.4.28\",\"platform\":\"iOS 18.4.0\",\"deviceFamily\":\"iPhone\",\"modelIdentifier\":\"iPhone17,1\",\"pushTransport\":\"relay\"}" +} +``` + +`trigger` is a closed enum: `background`, `silent_push`, `bg_app_refresh`, +`significant_location`, `manual`, or `connect`. Unknown trigger strings are normalized to +`background` by the gateway before persistence. The event is durable only for authenticated node +device sessions; device-less or unpaired sessions return `handled: false`. + +Successful gateways return a structured result: + +```json +{ + "ok": true, + "event": "node.presence.alive", + "handled": true, + "reason": "persisted" +} +``` + +Older gateways may still return `{ "ok": true }` for `node.event`; clients should treat that as an +acknowledged RPC, not as durable presence persistence. ## Broadcast event scoping diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index eebf35a3d04..7ea4153cdc7 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -114,6 +114,17 @@ Expected operator flow: 4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds. 5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration. +## Background alive beacons + +When iOS wakes the app for a silent push, background refresh, or significant-location event, the app +attempts a short node reconnect and then calls `node.event` with `event: "node.presence.alive"`. +The gateway records this as `lastSeenAtMs`/`lastSeenReason` on the paired node/device metadata only +after the authenticated node device identity is known. + +The app treats a background wake as successfully recorded only when the gateway response includes +`handled: true`. Older gateways may acknowledge `node.event` with `{ "ok": true }`; that response is +compatible but does not count as a durable last-seen update. + Compatibility note: - `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway. diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 5fdb860706b..5ae87a01210 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -72,6 +72,9 @@ function camelCase(input: string) { function safeName(name: string) { const cc = camelCase(name.replace(/-/g, "_")); + if (/^\d/.test(cc)) { + return `_${cc}`; + } if (reserved.has(cc)) { return `_${cc}`; } @@ -152,6 +155,16 @@ function swiftType(schema: JsonSchema, required: boolean, allowStructuralNamed = return isOptional ? `${base}?` : base; } +function emitEnum(name: string, schema: JsonSchema): string { + const cases = schema.enum ?? []; + return [ + `public enum ${name}: String, Codable, Sendable {`, + ...cases.map((value) => ` case ${safeName(value)} = "${value}"`), + "}", + "", + ].join("\n"); +} + function emitStruct(name: string, schema: JsonSchema): string { const props = schema.properties ?? {}; const required = new Set(schema.required ?? []); @@ -262,7 +275,16 @@ async function generate() { const parts: string[] = []; parts.push(header); - // Value structs + // Named enums and value structs + for (const [name, schema] of definitions) { + if (name === "GatewayFrame") { + continue; + } + if (schema.type === "string" && schema.enum) { + parts.push(emitEnum(name, schema)); + } + } + for (const [name, schema] of definitions) { if (name === "GatewayFrame") { continue; diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index 2faf60e793c..05a1e1a4651 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -135,6 +135,8 @@ describe("gateway/node-catalog", () => { pathEnv: "/usr/bin:/bin", approvedAtMs: 100, connectedAtMs, + lastSeenAtMs: connectedAtMs, + lastSeenReason: "connect", paired: true, connected: true, }), @@ -171,6 +173,8 @@ describe("gateway/node-catalog", () => { platform: "darwin", caps: ["system"], commands: ["system.run"], + lastSeenAtMs: 456, + lastSeenReason: "silent_push", createdAtMs: 1, approvedAtMs: 123, }, @@ -193,12 +197,62 @@ describe("gateway/node-catalog", () => { caps: ["system"], commands: ["system.run"], approvedAtMs: 123, + lastSeenAtMs: 456, + lastSeenReason: "silent_push", paired: true, connected: false, }), ); }); + it("uses the newest durable last-seen source for offline nodes", () => { + const catalog = createKnownNodeCatalog({ + pairedDevices: [ + { + deviceId: "ios-1", + publicKey: "public-key", + displayName: "iPhone", + role: "node", + roles: ["node"], + tokens: { + node: { + token: "current-token", + role: "node", + scopes: [], + createdAtMs: 1, + }, + }, + lastSeenAtMs: 300, + lastSeenReason: "silent_push", + createdAtMs: 1, + approvedAtMs: 10, + }, + ], + pairedNodes: [ + { + nodeId: "ios-1", + token: "node-token", + platform: "ios", + caps: [], + commands: [], + lastConnectedAtMs: 200, + lastSeenAtMs: 100, + lastSeenReason: "bg_app_refresh", + createdAtMs: 1, + approvedAtMs: 11, + }, + ], + connectedNodes: [], + }); + + expect(getKnownNode(catalog, "ios-1")).toEqual( + expect.objectContaining({ + lastSeenAtMs: 300, + lastSeenReason: "silent_push", + }), + ); + }); + it("prefers the live command surface for connected nodes", () => { const catalog = createKnownNodeCatalog({ pairedDevices: [], diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index 8ceda8071de..493d0c2d39b 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -12,6 +12,8 @@ export type KnownNodeDevicePairingSource = { clientMode?: string; remoteIp?: string; approvedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type KnownNodeApprovedSource = { @@ -28,6 +30,9 @@ export type KnownNodeApprovedSource = { commands: string[]; permissions?: Record; approvedAtMs?: number; + lastConnectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type KnownNodeEntry = { @@ -67,6 +72,8 @@ function buildDevicePairingSource(entry: PairedDevice): KnownNodeDevicePairingSo clientMode: entry.clientMode, remoteIp: entry.remoteIp, approvedAtMs: entry.approvedAtMs, + lastSeenAtMs: entry.lastSeenAtMs, + lastSeenReason: entry.lastSeenReason, }; } @@ -85,6 +92,36 @@ function buildApprovedNodeSource(entry: NodePairingPairedNode): KnownNodeApprove commands: entry.commands ?? [], permissions: entry.permissions, approvedAtMs: entry.approvedAtMs, + lastConnectedAtMs: entry.lastConnectedAtMs, + lastSeenAtMs: entry.lastSeenAtMs, + lastSeenReason: entry.lastSeenReason, + }; +} + +function resolveEffectiveLastSeen(params: { + live?: NodeSession; + devicePairing?: KnownNodeDevicePairingSource; + nodePairing?: KnownNodeApprovedSource; +}): { lastSeenAtMs?: number; lastSeenReason?: string } { + const candidates: Array<{ atMs: number; reason?: string }> = [ + params.live?.connectedAtMs ? { atMs: params.live.connectedAtMs, reason: "connect" } : undefined, + params.nodePairing?.lastSeenAtMs + ? { atMs: params.nodePairing.lastSeenAtMs, reason: params.nodePairing.lastSeenReason } + : undefined, + params.nodePairing?.lastConnectedAtMs + ? { atMs: params.nodePairing.lastConnectedAtMs, reason: "connect" } + : undefined, + params.devicePairing?.lastSeenAtMs + ? { atMs: params.devicePairing.lastSeenAtMs, reason: params.devicePairing.lastSeenReason } + : undefined, + ].filter((entry) => entry !== undefined); + const newest = candidates.toSorted((left, right) => right.atMs - left.atMs)[0]; + if (!newest) { + return {}; + } + return { + lastSeenAtMs: newest.atMs, + lastSeenReason: newest.reason, }; } @@ -95,6 +132,7 @@ function buildEffectiveKnownNode(entry: { live?: NodeSession; }): NodeListNode { const { nodeId, devicePairing, nodePairing, live } = entry; + const lastSeen = resolveEffectiveLastSeen({ live, devicePairing, nodePairing }); return { nodeId, displayName: live?.displayName ?? nodePairing?.displayName ?? devicePairing?.displayName, @@ -114,6 +152,8 @@ function buildEffectiveKnownNode(entry: { pathEnv: live?.pathEnv, permissions: live?.permissions ?? nodePairing?.permissions, connectedAtMs: live?.connectedAtMs, + lastSeenAtMs: lastSeen.lastSeenAtMs, + lastSeenReason: lastSeen.lastSeenReason, approvedAtMs: nodePairing?.approvedAtMs ?? devicePairing?.approvedAtMs, paired: Boolean(devicePairing ?? nodePairing), connected: Boolean(live), diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index 8ba18ed23b1..34b325f5604 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -4,6 +4,8 @@ import { TALK_TEST_PROVIDER_ID } from "../../test-utils/talk-test-provider.js"; import { formatValidationErrors, validateModelsListParams, + validateNodeEventResult, + validateNodePresenceAlivePayload, validateTalkConfigResult, validateTalkRealtimeSessionParams, validateWakeParams, @@ -190,3 +192,43 @@ describe("validateModelsListParams", () => { expect(validateModelsListParams({ view: "configured", provider: "minimax" })).toBe(false); }); }); + +describe("validateNodePresenceAlivePayload", () => { + it("accepts a closed trigger and known metadata fields", () => { + expect( + validateNodePresenceAlivePayload({ + trigger: "silent_push", + sentAtMs: 123, + displayName: "Peter's iPhone", + version: "2026.4.28", + platform: "iOS 18.4.0", + deviceFamily: "iPhone", + modelIdentifier: "iPhone17,1", + pushTransport: "relay", + }), + ).toBe(true); + }); + + it("rejects unknown triggers and extra fields", () => { + expect(validateNodePresenceAlivePayload({ trigger: "push", sentAtMs: 123 })).toBe(false); + expect( + validateNodePresenceAlivePayload({ + trigger: "silent_push", + arbitrary: true, + }), + ).toBe(false); + }); +}); + +describe("validateNodeEventResult", () => { + it("accepts structured handled results", () => { + expect( + validateNodeEventResult({ + ok: true, + event: "node.presence.alive", + handled: true, + reason: "persisted", + }), + ).toBe(true); + }); +}); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 064fc47b749..1d27dbaf3b6 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -177,6 +177,8 @@ import { NodeDescribeParamsSchema, type NodeEventParams, NodeEventParamsSchema, + type NodeEventResult, + NodeEventResultSchema, type NodePendingDrainParams, NodePendingDrainParamsSchema, type NodePendingDrainResult, @@ -185,6 +187,10 @@ import { NodePendingEnqueueParamsSchema, type NodePendingEnqueueResult, NodePendingEnqueueResultSchema, + type NodePresenceAlivePayload, + NodePresenceAlivePayloadSchema, + type NodePresenceAliveReason, + NodePresenceAliveReasonSchema, type NodeInvokeParams, NodeInvokeParamsSchema, type NodeInvokeResultParams, @@ -388,6 +394,10 @@ export const validateNodeInvokeResultParams = ajv.compile(NodeEventParamsSchema); +export const validateNodeEventResult = ajv.compile(NodeEventResultSchema); +export const validateNodePresenceAlivePayload = ajv.compile( + NodePresenceAlivePayloadSchema, +); export const validateNodePendingDrainParams = ajv.compile( NodePendingDrainParamsSchema, ); @@ -651,6 +661,9 @@ export { NodeListParamsSchema, NodePendingAckParamsSchema, NodeInvokeParamsSchema, + NodeEventResultSchema, + NodePresenceAlivePayloadSchema, + NodePresenceAliveReasonSchema, NodePendingDrainParamsSchema, NodePendingDrainResultSchema, NodePendingEnqueueParamsSchema, @@ -857,6 +870,9 @@ export type { NodeInvokeParams, NodeInvokeResultParams, NodeEventParams, + NodeEventResult, + NodePresenceAlivePayload, + NodePresenceAliveReason, NodePendingDrainParams, NodePendingDrainResult, NodePendingEnqueueParams, diff --git a/src/gateway/protocol/schema/nodes.ts b/src/gateway/protocol/schema/nodes.ts index 8e6534076d6..eed4bf161b9 100644 --- a/src/gateway/protocol/schema/nodes.ts +++ b/src/gateway/protocol/schema/nodes.ts @@ -9,6 +9,41 @@ const NodePendingWorkPrioritySchema = Type.String({ enum: ["normal", "high"], }); +export const NodePresenceAliveReasonSchema = Type.String({ + enum: [ + "background", + "silent_push", + "bg_app_refresh", + "significant_location", + "manual", + "connect", + ], +}); + +export const NodePresenceAlivePayloadSchema = Type.Object( + { + trigger: NodePresenceAliveReasonSchema, + sentAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + displayName: Type.Optional(NonEmptyString), + version: Type.Optional(NonEmptyString), + platform: Type.Optional(NonEmptyString), + deviceFamily: Type.Optional(NonEmptyString), + modelIdentifier: Type.Optional(NonEmptyString), + pushTransport: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + +export const NodeEventResultSchema = Type.Object( + { + ok: Type.Boolean(), + event: NonEmptyString, + handled: Type.Boolean(), + reason: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + export const NodePairRequestParamsSchema = Type.Object( { nodeId: NonEmptyString, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index e53abc53946..1f3b90a6f70 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -140,10 +140,13 @@ import { import { NodeDescribeParamsSchema, NodeEventParamsSchema, + NodeEventResultSchema, NodePendingDrainParamsSchema, NodePendingDrainResultSchema, NodePendingEnqueueParamsSchema, NodePendingEnqueueResultSchema, + NodePresenceAlivePayloadSchema, + NodePresenceAliveReasonSchema, NodeInvokeParamsSchema, NodeInvokeResultParamsSchema, NodeInvokeRequestEventSchema, @@ -244,6 +247,9 @@ export const ProtocolSchemas = { NodeInvokeParams: NodeInvokeParamsSchema, NodeInvokeResultParams: NodeInvokeResultParamsSchema, NodeEventParams: NodeEventParamsSchema, + NodeEventResult: NodeEventResultSchema, + NodePresenceAlivePayload: NodePresenceAlivePayloadSchema, + NodePresenceAliveReason: NodePresenceAliveReasonSchema, NodePendingDrainParams: NodePendingDrainParamsSchema, NodePendingDrainResult: NodePendingDrainResultSchema, NodePendingEnqueueParams: NodePendingEnqueueParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 82cde9ecda4..a956f9d984f 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -34,6 +34,9 @@ export type NodeDescribeParams = SchemaType<"NodeDescribeParams">; export type NodeInvokeParams = SchemaType<"NodeInvokeParams">; export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">; export type NodeEventParams = SchemaType<"NodeEventParams">; +export type NodeEventResult = SchemaType<"NodeEventResult">; +export type NodePresenceAlivePayload = SchemaType<"NodePresenceAlivePayload">; +export type NodePresenceAliveReason = SchemaType<"NodePresenceAliveReason">; export type NodePendingDrainParams = SchemaType<"NodePendingDrainParams">; export type NodePendingDrainResult = SchemaType<"NodePendingDrainResult">; export type NodePendingEnqueueParams = SchemaType<"NodePendingEnqueueParams">; diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 1f85ea6e8f2..27f6a9dda3d 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -1164,11 +1164,16 @@ export const nodeHandlers: GatewayRequestHandlers = { loadGatewayModelCatalog: context.loadGatewayModelCatalog, logGateway: { warn: context.logGateway.warn }, }; - await handleNodeEvent(nodeContext, nodeId, { - event: p.event, - payloadJSON, - }); - respond(true, { ok: true }, undefined); + const result = await handleNodeEvent( + nodeContext, + nodeId, + { + event: p.event, + payloadJSON, + }, + { deviceId: client?.connect?.device?.id }, + ); + respond(true, result ?? { ok: true }, undefined); }); }, }; diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index b084e7e05eb..d00d7114d82 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -61,6 +61,8 @@ const sanitizeInboundSystemTagsMock = vi.hoisted(() => .replace(/^(\s*)System:(?=\s|$)/gim, "$1System (untrusted):"), ), ); +const updatePairedDeviceMetadataMock = vi.hoisted(() => vi.fn().mockResolvedValue(true)); +const updatePairedNodeMetadataMock = vi.hoisted(() => vi.fn().mockResolvedValue(true)); const runtimeMocks = vi.hoisted(() => ({ agentCommandFromIngress: ingressAgentCommandMock, @@ -132,11 +134,21 @@ const runtimeMocks = vi.hoisted(() => ({ })); vi.mock("./server-node-events.runtime.js", () => runtimeMocks); +vi.mock("../infra/device-pairing.js", () => ({ + updatePairedDeviceMetadata: updatePairedDeviceMetadataMock, +})); +vi.mock("../infra/node-pairing.js", () => ({ + updatePairedNodeMetadata: updatePairedNodeMetadataMock, +})); import type { CliDeps } from "../cli/deps.js"; import type { HealthSummary } from "../commands/health.js"; import type { NodeEventContext } from "./server-node-events-types.js"; -import { handleNodeEvent, resetNodeEventDeduplicationForTests } from "./server-node-events.js"; +import { + getRecentNodePresencePersistCountForTests, + handleNodeEvent, + resetNodeEventDeduplicationForTests, +} from "./server-node-events.js"; const enqueueSystemEventMock = runtimeMocks.enqueueSystemEvent; const requestHeartbeatNowMock = runtimeMocks.requestHeartbeatNow; @@ -181,6 +193,10 @@ describe("node exec events", () => { normalizeChannelIdVi.mockClear(); normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null); sanitizeInboundSystemTagsMock.mockClear(); + updatePairedDeviceMetadataMock.mockClear(); + updatePairedDeviceMetadataMock.mockResolvedValue(true); + updatePairedNodeMetadataMock.mockClear(); + updatePairedNodeMetadataMock.mockResolvedValue(true); }); it("enqueues exec.started events", async () => { @@ -998,4 +1014,139 @@ describe("agent request events", () => { expect(agentCommandMock).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/attachment parse failed.*non-image/i)); }); + + beforeEach(() => { + resetNodeEventDeduplicationForTests(); + updatePairedDeviceMetadataMock.mockClear(); + updatePairedDeviceMetadataMock.mockResolvedValue(true); + updatePairedNodeMetadataMock.mockClear(); + updatePairedNodeMetadataMock.mockResolvedValue(true); + }); + + it("persists authenticated node presence alive events", async () => { + const ctx = buildCtx(); + const result = await handleNodeEvent( + ctx, + "ios-node", + { + event: "node.presence.alive", + payloadJSON: JSON.stringify({ + trigger: "bg_app_refresh", + sentAtMs: 123, + }), + }, + { deviceId: "ios-node" }, + ); + + expect(result).toEqual({ + ok: true, + event: "node.presence.alive", + handled: true, + reason: "persisted", + }); + expect(updatePairedNodeMetadataMock).toHaveBeenCalledWith("ios-node", { + lastSeenAtMs: expect.any(Number), + lastSeenReason: "bg_app_refresh", + }); + expect(updatePairedDeviceMetadataMock).toHaveBeenCalledWith("ios-node", { + lastSeenAtMs: expect.any(Number), + lastSeenReason: "bg_app_refresh", + }); + expect(getRecentNodePresencePersistCountForTests()).toBe(1); + }); + + it("rejects node presence alive events without authenticated device identity", async () => { + const ctx = buildCtx(); + const result = await handleNodeEvent(ctx, "ios-node", { + event: "node.presence.alive", + payloadJSON: JSON.stringify({ trigger: "silent_push" }), + }); + + expect(result).toEqual({ + ok: true, + event: "node.presence.alive", + handled: false, + reason: "missing_device_identity", + }); + expect(updatePairedNodeMetadataMock).not.toHaveBeenCalled(); + expect(updatePairedDeviceMetadataMock).not.toHaveBeenCalled(); + expect(getRecentNodePresencePersistCountForTests()).toBe(0); + }); + + it("normalizes unknown node presence alive triggers before persistence", async () => { + const ctx = buildCtx(); + await handleNodeEvent( + ctx, + "ios-node", + { + event: "node.presence.alive", + payloadJSON: JSON.stringify({ trigger: "x".repeat(4096) }), + }, + { deviceId: "ios-node" }, + ); + + expect(updatePairedNodeMetadataMock).toHaveBeenCalledWith("ios-node", { + lastSeenAtMs: expect.any(Number), + lastSeenReason: "background", + }); + expect(updatePairedDeviceMetadataMock).toHaveBeenCalledWith("ios-node", { + lastSeenAtMs: expect.any(Number), + lastSeenReason: "background", + }); + }); + + it("does not throttle unknown node presence alive identities", async () => { + updatePairedNodeMetadataMock.mockResolvedValue(false); + updatePairedDeviceMetadataMock.mockResolvedValue(false); + const ctx = buildCtx(); + const result = await handleNodeEvent( + ctx, + "ios-node", + { + event: "node.presence.alive", + payloadJSON: JSON.stringify({ trigger: "silent_push" }), + }, + { deviceId: "ios-node" }, + ); + + expect(result).toEqual({ + ok: true, + event: "node.presence.alive", + handled: false, + reason: "unpaired", + }); + expect(getRecentNodePresencePersistCountForTests()).toBe(0); + }); + + it("throttles repeated node presence alive persistence per device", async () => { + const ctx = buildCtx(); + await handleNodeEvent( + ctx, + "ios-node", + { + event: "node.presence.alive", + payloadJSON: JSON.stringify({ trigger: "silent_push" }), + }, + { deviceId: "ios-node" }, + ); + const result = await handleNodeEvent( + ctx, + "ios-node", + { + event: "node.presence.alive", + payloadJSON: JSON.stringify({ trigger: "silent_push" }), + }, + { deviceId: "ios-node" }, + ); + + expect(result).toEqual({ + ok: true, + event: "node.presence.alive", + handled: true, + reason: "throttled", + }); + expect(updatePairedNodeMetadataMock).toHaveBeenCalledTimes(1); + expect(updatePairedDeviceMetadataMock).toHaveBeenCalledTimes(1); + expect(getRecentNodePresencePersistCountForTests()).toBe(1); + }); }); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 31bcfc1595f..79efd355635 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -1,7 +1,13 @@ import { randomUUID } from "node:crypto"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { updatePairedDeviceMetadata } from "../infra/device-pairing.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { updatePairedNodeMetadata } from "../infra/node-pairing.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; +import { + NODE_PRESENCE_ALIVE_EVENT, + normalizeNodePresenceAliveReason, +} from "../shared/node-presence.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -42,9 +48,19 @@ const VOICE_TRANSCRIPT_DEDUPE_WINDOW_MS = 1500; const MAX_RECENT_VOICE_TRANSCRIPTS = 200; const EXEC_FINISHED_RUN_DEDUPE_WINDOW_MS = 10 * 60 * 1000; const MAX_RECENT_EXEC_FINISHED_RUNS = 2000; +const NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS = 60_000; +const MAX_RECENT_NODE_PRESENCE_KEYS = 1024; const recentVoiceTranscripts = new Map(); const recentExecFinishedRuns = new Map(); +const recentNodePresencePersistAt = new Map(); + +export type NodeEventHandleResult = { + ok: true; + event: string; + handled: boolean; + reason?: string; +}; function normalizeFiniteInteger(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : null; @@ -157,9 +173,39 @@ function shouldDropDuplicateExecFinished(params: { return false; } +function pruneBoundedTimestampMap( + map: Map, + params: { now: number; ttlMs: number; maxEntries: number }, +) { + if (map.size <= params.maxEntries) { + return; + } + const cutoff = params.now - params.ttlMs; + for (const [key, ts] of map) { + if (ts < cutoff) { + map.delete(key); + } + if (map.size <= params.maxEntries) { + return; + } + } + while (map.size > params.maxEntries) { + const oldestKey = map.keys().next().value; + if (oldestKey === undefined) { + return; + } + map.delete(oldestKey); + } +} + export function resetNodeEventDeduplicationForTests() { recentVoiceTranscripts.clear(); recentExecFinishedRuns.clear(); + recentNodePresencePersistAt.clear(); +} + +export function getRecentNodePresencePersistCountForTests() { + return recentNodePresencePersistAt.size; } function compactExecEventOutput(raw: string) { @@ -310,19 +356,24 @@ async function sendReceiptAck(params: { }); } -export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => { +export const handleNodeEvent = async ( + ctx: NodeEventContext, + nodeId: string, + evt: NodeEvent, + opts?: { deviceId?: string }, +): Promise => { switch (evt.event) { case "voice.transcript": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) { - return; + return undefined; } const text = normalizeOptionalString(obj.text) ?? ""; if (!text) { - return; + return undefined; } if (text.length > 20_000) { - return; + return undefined; } const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? ""; const cfg = getRuntimeConfig(); @@ -332,7 +383,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt const now = Date.now(); const fingerprint = resolveVoiceTranscriptFingerprint(obj, text); if (shouldDropDuplicateVoiceTranscript({ sessionKey: canonicalKey, fingerprint, now })) { - return; + return undefined; } const sessionId = entry?.sessionId ?? randomUUID(); queueSessionStoreTouch({ @@ -376,11 +427,11 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt ).catch((err) => { ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`); }); - return; + return undefined; } case "agent.request": { if (!evt.payloadJSON) { - return; + return undefined; } type AgentDeepLink = { message?: string; @@ -405,7 +456,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt try { link = JSON.parse(evt.payloadJSON) as AgentDeepLink; } catch { - return; + return undefined; } const sessionKeyRaw = (link?.sessionKey ?? "").trim(); @@ -420,10 +471,10 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt let images: Array<{ type: "image"; data: string; mimeType: string }> = []; let imageOrder: PromptImageOrderEntry[] = []; if (!message && normalizedAttachments.length === 0) { - return; + return undefined; } if (message.length > 20_000) { - return; + return undefined; } if (normalizedAttachments.length > 0) { const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg }); @@ -461,16 +512,16 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt } } } - return; + return undefined; } } catch (err) { ctx.logGateway.warn(`agent.request attachment parse failed: ${formatErrorMessage(err)}`); - return; + return undefined; } } if (!message && images.length === 0) { - return; + return undefined; } const channelRaw = normalizeOptionalString(link?.channel) ?? ""; @@ -548,22 +599,22 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt ).catch((err) => { ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`); }); - return; + return undefined; } case "notifications.changed": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) { - return; + return undefined; } const change = normalizeOptionalString(obj.change) ? normalizeLowercaseStringOrEmpty(obj.change) : undefined; if (change !== "posted" && change !== "removed") { - return; + return undefined; } const keyRaw = normalizeOptionalString(obj.key); if (!keyRaw) { - return; + return undefined; } const key = sanitizeInboundSystemTags(keyRaw); const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`; @@ -597,40 +648,40 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt if (queued) { requestHeartbeatNow({ reason: "notifications-event", sessionKey }); } - return; + return undefined; } case "chat.subscribe": { if (!evt.payloadJSON) { - return; + return undefined; } const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON); if (!sessionKey) { - return; + return undefined; } ctx.nodeSubscribe(nodeId, sessionKey); - return; + return undefined; } case "chat.unsubscribe": { if (!evt.payloadJSON) { - return; + return undefined; } const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON); if (!sessionKey) { - return; + return undefined; } ctx.nodeUnsubscribe(nodeId, sessionKey); - return; + return undefined; } case "exec.started": case "exec.finished": case "exec.denied": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) { - return; + return undefined; } const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`; if (!sessionKeyRaw) { - return; + return undefined; } const { canonicalKey: sessionKey } = loadSessionEntry(sessionKeyRaw); @@ -639,10 +690,10 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt const cfg = getRuntimeConfig(); const notifyOnExit = cfg.tools?.exec?.notifyOnExit !== false; if (!notifyOnExit) { - return; + return undefined; } if (obj.suppressNotifyOnExit === true) { - return; + return undefined; } const runId = normalizeOptionalString(obj.runId) ?? ""; @@ -666,7 +717,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt const compactOutput = compactExecEventOutput(output); const shouldNotify = timedOut || exitCode !== 0 || compactOutput.length > 0; if (!shouldNotify) { - return; + return undefined; } if ( runId && @@ -676,7 +727,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt now: Date.now(), }) ) { - return; + return undefined; } text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`; if (compactOutput) { @@ -702,12 +753,12 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event", coalesceMs: 0 }), ); } - return; + return undefined; } case "push.apns.register": { const obj = parsePayloadObject(evt.payloadJSON); if (!obj) { - return; + return undefined; } const transport = normalizeLowercaseStringOrEmpty(obj.transport) || "direct"; const topic = typeof obj.topic === "string" ? obj.topic : ""; @@ -720,7 +771,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt ctx.logGateway.warn( `push relay register rejected node=${nodeId}: gateway identity mismatch`, ); - return; + return undefined; } await registerApnsRegistration({ nodeId, @@ -745,9 +796,51 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt } catch (err) { ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`); } - return; + return undefined; + } + case NODE_PRESENCE_ALIVE_EVENT: { + const obj = parsePayloadObject(evt.payloadJSON); + if (!obj) { + return { ok: true, event: evt.event, handled: false, reason: "invalid_payload" }; + } + const deviceId = normalizeOptionalString(opts?.deviceId); + if (!deviceId) { + return { ok: true, event: evt.event, handled: false, reason: "missing_device_identity" }; + } + const now = Date.now(); + const lastPersistedAt = recentNodePresencePersistAt.get(deviceId) ?? 0; + if (now - lastPersistedAt < NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS) { + return { ok: true, event: evt.event, handled: true, reason: "throttled" }; + } + + const lastSeenReason = normalizeNodePresenceAliveReason(obj.trigger); + try { + const [nodeUpdated, deviceUpdated] = await Promise.all([ + updatePairedNodeMetadata(nodeId, { + lastSeenAtMs: now, + lastSeenReason, + }), + updatePairedDeviceMetadata(deviceId, { + lastSeenAtMs: now, + lastSeenReason, + }), + ]); + if (!nodeUpdated && !deviceUpdated) { + return { ok: true, event: evt.event, handled: false, reason: "unpaired" }; + } + recentNodePresencePersistAt.set(deviceId, now); + pruneBoundedTimestampMap(recentNodePresencePersistAt, { + now, + ttlMs: NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS * 10, + maxEntries: MAX_RECENT_NODE_PRESENCE_KEYS, + }); + return { ok: true, event: evt.event, handled: true, reason: "persisted" }; + } catch (err) { + ctx.logGateway.warn(`node presence alive failed node=${nodeId}: ${formatForLog(err)}`); + return { ok: true, event: evt.event, handled: false, reason: "persist_failed" }; + } } default: - return; + return undefined; } }; diff --git a/src/infra/device-pairing.test.ts b/src/infra/device-pairing.test.ts index bf65f2b5f42..166424cb08b 100644 --- a/src/infra/device-pairing.test.ts +++ b/src/infra/device-pairing.test.ts @@ -554,6 +554,33 @@ describe("device pairing tokens", () => { expect(paired?.tokens?.operator).toBeUndefined(); }); + test("metadata refresh persists last-seen fields and reports missing devices", async () => { + const baseDir = await makeDevicePairingDir(); + await setupPairedNodeDevice(baseDir); + + await expect( + updatePairedDeviceMetadata( + "node-1", + { + lastSeenAtMs: 4321, + lastSeenReason: "bg_app_refresh", + }, + baseDir, + ), + ).resolves.toBe(true); + await expect(updatePairedDeviceMetadata("missing", { lastSeenAtMs: 1 }, baseDir)).resolves.toBe( + false, + ); + + const paired = await getPairedDevice("node-1", baseDir); + expect(paired).toEqual( + expect.objectContaining({ + lastSeenAtMs: 4321, + lastSeenReason: "bg_app_refresh", + }), + ); + }); + test("generates base64url device tokens with 256-bit entropy output length", async () => { const baseDir = await makeDevicePairingDir(); await setupPairedOperatorDevice(baseDir, ["operator.admin"]); diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 27062f7db93..bcc4ddb4560 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -89,11 +89,13 @@ export type PairedDevice = { tokens?: Record; createdAtMs: number; approvedAtMs: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type PairedDeviceMetadataPatch = Pick< PairedDevice, - "displayName" | "clientId" | "clientMode" | "remoteIp" + "displayName" | "clientId" | "clientMode" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason" >; export type DevicePairingList = { @@ -793,13 +795,13 @@ export async function updatePairedDeviceMetadata( deviceId: string, patch: Partial, baseDir?: string, -): Promise { +): Promise { return await withLock(async () => { const state = await loadState(baseDir); const normalizedDeviceId = normalizeDeviceId(deviceId); const existing = state.pairedByDeviceId[normalizedDeviceId]; if (!existing) { - return; + return false; } const next = { ...existing }; if ("displayName" in patch) { @@ -814,8 +816,15 @@ export async function updatePairedDeviceMetadata( if ("remoteIp" in patch) { next.remoteIp = patch.remoteIp; } + if ("lastSeenAtMs" in patch) { + next.lastSeenAtMs = patch.lastSeenAtMs; + } + if ("lastSeenReason" in patch) { + next.lastSeenReason = patch.lastSeenReason; + } state.pairedByDeviceId[normalizedDeviceId] = next; await persistState(state, baseDir, "paired"); + return true; }); } diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index b58ad9838b8..15635e0650b 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -7,6 +7,7 @@ import { listNodePairing, removePairedNode, requestNodePairing, + updatePairedNodeMetadata, verifyNodeToken, } from "./node-pairing.js"; import { resolvePairingPaths } from "./pairing-files.js"; @@ -250,4 +251,31 @@ describe("node pairing tokens", () => { await expect(fs.readFile(pairedPath, "utf8")).resolves.toBe("{not-json}"); }); }); + + test("updates paired node last-seen metadata and reports missing nodes", async () => { + await withNodePairingDir(async (baseDir) => { + await setupPairedNode(baseDir); + + await expect( + updatePairedNodeMetadata( + "node-1", + { + lastSeenAtMs: 1234, + lastSeenReason: "silent_push", + }, + baseDir, + ), + ).resolves.toBe(true); + await expect(updatePairedNodeMetadata("missing", { lastSeenAtMs: 1 }, baseDir)).resolves.toBe( + false, + ); + + await expect(getPairedNode("node-1", baseDir)).resolves.toEqual( + expect.objectContaining({ + lastSeenAtMs: 1234, + lastSeenReason: "silent_push", + }), + ); + }); + }); }); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index bda53c5fa2d..447248f3ed5 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -50,6 +50,8 @@ export type NodePairingPairedNode = NodeApprovedSurface & { createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type NodePairingList = { @@ -321,13 +323,13 @@ export async function updatePairedNodeMetadata( nodeId: string, patch: Partial>, baseDir?: string, -) { - await withLock(async () => { +): Promise { + return await withLock(async () => { const state = await loadState(baseDir); const normalized = normalizeNodeId(nodeId); const existing = state.pairedByNodeId[normalized]; if (!existing) { - return; + return false; } const next: NodePairingPairedNode = { @@ -345,10 +347,13 @@ export async function updatePairedNodeMetadata( bins: patch.bins ?? existing.bins, permissions: patch.permissions ?? existing.permissions, lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs, + lastSeenAtMs: patch.lastSeenAtMs ?? existing.lastSeenAtMs, + lastSeenReason: patch.lastSeenReason ?? existing.lastSeenReason, }; state.pairedByNodeId[normalized] = next; await persistState(state, baseDir); + return true; }); } diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index 21216b0fb10..b80e02c5cc4 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -17,6 +17,8 @@ export type NodeListNode = { paired?: boolean; connected?: boolean; connectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; approvedAtMs?: number; }; @@ -47,6 +49,8 @@ export type PairedNode = { createdAtMs?: number; approvedAtMs?: number; lastConnectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type PairingList = { diff --git a/src/shared/node-presence.ts b/src/shared/node-presence.ts new file mode 100644 index 00000000000..f6334ee1d2c --- /dev/null +++ b/src/shared/node-presence.ts @@ -0,0 +1,24 @@ +import { normalizeOptionalString } from "./string-coerce.js"; + +export const NODE_PRESENCE_ALIVE_EVENT = "node.presence.alive"; + +export const NODE_PRESENCE_ALIVE_REASONS = [ + "background", + "silent_push", + "bg_app_refresh", + "significant_location", + "manual", + "connect", +] as const; + +export type NodePresenceAliveReason = (typeof NODE_PRESENCE_ALIVE_REASONS)[number]; + +const NODE_PRESENCE_ALIVE_REASON_SET = new Set(NODE_PRESENCE_ALIVE_REASONS); + +export function normalizeNodePresenceAliveReason(value: unknown): NodePresenceAliveReason { + const normalized = normalizeOptionalString(value)?.toLowerCase(); + if (normalized && NODE_PRESENCE_ALIVE_REASON_SET.has(normalized)) { + return normalized as NodePresenceAliveReason; + } + return "background"; +}