mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
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>
This commit is contained in:
committed by
GitHub
parent
d525d6486d
commit
bdba90a20b
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ final class NodeAppModel {
|
|||||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||||
|
|
||||||
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
|
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 cameraHUDText: String?
|
||||||
var cameraHUDKind: CameraHUDKind?
|
var cameraHUDKind: CameraHUDKind?
|
||||||
@@ -3142,32 +3144,39 @@ extension NodeAppModel {
|
|||||||
return handled
|
return handled
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
let result = await self.performBackgroundAliveBeaconIfNeeded(
|
||||||
|
wakeId: wakeId,
|
||||||
|
trigger: .silentPush)
|
||||||
let outcomeMessage =
|
let outcomeMessage =
|
||||||
"Silent push outcome wakeId=\(wakeId) "
|
"Silent push outcome wakeId=\(wakeId) "
|
||||||
+ "applied=\(result.applied) "
|
+ "applied=\(result.applied) "
|
||||||
|
+ "handled=\(result.handled) "
|
||||||
+ "reason=\(result.reason) "
|
+ "reason=\(result.reason) "
|
||||||
+ "durationMs=\(result.durationMs)"
|
+ "durationMs=\(result.durationMs)"
|
||||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||||
return result.applied
|
return result.handled
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
|
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
|
||||||
let wakeId = Self.makePushWakeAttemptID()
|
let wakeId = Self.makePushWakeAttemptID()
|
||||||
|
let normalizedTrigger = BackgroundAliveBeacon.normalizeTrigger(trigger)
|
||||||
let receivedMessage =
|
let receivedMessage =
|
||||||
"Background refresh wake received wakeId=\(wakeId) "
|
"Background refresh wake received wakeId=\(wakeId) "
|
||||||
+ "trigger=\(trigger) "
|
+ "trigger=\(normalizedTrigger.rawValue) "
|
||||||
+ "backgrounded=\(self.isBackgrounded) "
|
+ "backgrounded=\(self.isBackgrounded) "
|
||||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
let result = await self.performBackgroundAliveBeaconIfNeeded(
|
||||||
|
wakeId: wakeId,
|
||||||
|
trigger: normalizedTrigger)
|
||||||
let outcomeMessage =
|
let outcomeMessage =
|
||||||
"Background refresh wake outcome wakeId=\(wakeId) "
|
"Background refresh wake outcome wakeId=\(wakeId) "
|
||||||
+ "applied=\(result.applied) "
|
+ "applied=\(result.applied) "
|
||||||
|
+ "handled=\(result.handled) "
|
||||||
+ "reason=\(result.reason) "
|
+ "reason=\(result.reason) "
|
||||||
+ "durationMs=\(result.durationMs)"
|
+ "durationMs=\(result.durationMs)"
|
||||||
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
|
||||||
return result.applied
|
return result.handled
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSignificantLocationWakeIfNeeded() async {
|
func handleSignificantLocationWakeIfNeeded() async {
|
||||||
@@ -3196,10 +3205,13 @@ extension NodeAppModel {
|
|||||||
+ "backgrounded=\(self.isBackgrounded) "
|
+ "backgrounded=\(self.isBackgrounded) "
|
||||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||||
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
self.locationWakeLogger.info("\(beginMessage, privacy: .public)")
|
||||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
let result = await self.performBackgroundAliveBeaconIfNeeded(
|
||||||
|
wakeId: wakeId,
|
||||||
|
trigger: .significantLocation)
|
||||||
let triggerMessage =
|
let triggerMessage =
|
||||||
"Location wake trigger wakeId=\(wakeId) "
|
"Location wake trigger wakeId=\(wakeId) "
|
||||||
+ "applied=\(result.applied) "
|
+ "applied=\(result.applied) "
|
||||||
|
+ "handled=\(result.handled) "
|
||||||
+ "reason=\(result.reason) "
|
+ "reason=\(result.reason) "
|
||||||
+ "durationMs=\(result.durationMs)"
|
+ "durationMs=\(result.durationMs)"
|
||||||
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
self.locationWakeLogger.info("\(triggerMessage, privacy: .public)")
|
||||||
@@ -3621,8 +3633,9 @@ extension NodeAppModel {
|
|||||||
return gatewayError.message.lowercased().contains("allow-always is unavailable")
|
return gatewayError.message.lowercased().contains("allow-always is unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SilentPushWakeAttemptResult {
|
private struct BackgroundAliveWakeAttemptResult {
|
||||||
var applied: Bool
|
var applied: Bool
|
||||||
|
var handled: Bool
|
||||||
var reason: String
|
var reason: String
|
||||||
var durationMs: Int
|
var durationMs: Int
|
||||||
}
|
}
|
||||||
@@ -3797,43 +3810,100 @@ extension NodeAppModel {
|
|||||||
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
|
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reconnectGatewaySessionsForSilentPushIfNeeded(
|
private func performBackgroundAliveBeaconIfNeeded(
|
||||||
wakeId: String) async -> SilentPushWakeAttemptResult
|
wakeId: String,
|
||||||
|
trigger: BackgroundAliveBeacon.Trigger) async -> BackgroundAliveWakeAttemptResult
|
||||||
{
|
{
|
||||||
let startedAt = Date()
|
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)
|
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||||
return SilentPushWakeAttemptResult(
|
return BackgroundAliveWakeAttemptResult(
|
||||||
applied: applied,
|
applied: applied,
|
||||||
|
handled: handled,
|
||||||
reason: reason,
|
reason: reason,
|
||||||
durationMs: max(0, durationMs))
|
durationMs: max(0, durationMs))
|
||||||
}
|
}
|
||||||
|
|
||||||
guard self.isBackgrounded else {
|
guard self.isBackgrounded else {
|
||||||
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded")
|
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 {
|
guard self.gatewayAutoReconnectEnabled else {
|
||||||
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled")
|
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 {
|
let now = Date()
|
||||||
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config")
|
let gatewayConnected = await self.isGatewayConnected()
|
||||||
return makeResult(false, "no_active_gateway_config")
|
|
||||||
|
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(
|
let beacon = await self.publishBackgroundAliveBeacon(trigger: trigger)
|
||||||
"Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)")
|
if beacon.handled {
|
||||||
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
|
let successAtMs = Date().timeIntervalSince1970 * 1000
|
||||||
await self.operatorGateway.disconnect()
|
UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey)
|
||||||
await self.nodeGateway.disconnect()
|
UserDefaults.standard.set(trigger.rawValue, forKey: Self.backgroundAliveLastTriggerKey)
|
||||||
self.operatorConnected = false
|
return makeResult(appliedReconnect, true, beacon.reason)
|
||||||
self.gatewayConnected = false
|
}
|
||||||
self.gatewayStatusText = "Reconnecting…"
|
return makeResult(appliedReconnect, false, beacon.reason)
|
||||||
self.talkMode.updateGatewayConnected(false)
|
}
|
||||||
self.applyGatewayConnectConfig(cfg)
|
|
||||||
self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)")
|
private func publishBackgroundAliveBeacon(
|
||||||
return makeResult(true, "reconnect_triggered")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
apps/ios/Sources/Push/BackgroundAliveBeacon.swift
Normal file
92
apps/ios/Sources/Push/BackgroundAliveBeacon.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ Sources/Onboarding/OnboardingWizardView.swift
|
|||||||
Sources/Onboarding/QRScannerView.swift
|
Sources/Onboarding/QRScannerView.swift
|
||||||
Sources/OpenClawApp.swift
|
Sources/OpenClawApp.swift
|
||||||
Sources/Push/ExecApprovalNotificationBridge.swift
|
Sources/Push/ExecApprovalNotificationBridge.swift
|
||||||
|
Sources/Push/BackgroundAliveBeacon.swift
|
||||||
Sources/Push/PushBuildConfig.swift
|
Sources/Push/PushBuildConfig.swift
|
||||||
Sources/Push/PushRegistrationManager.swift
|
Sources/Push/PushRegistrationManager.swift
|
||||||
Sources/Push/PushRelayClient.swift
|
Sources/Push/PushRelayClient.swift
|
||||||
|
|||||||
74
apps/ios/Tests/BackgroundAliveBeaconTests.swift
Normal file
74
apps/ios/Tests/BackgroundAliveBeaconTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,15 @@ public enum ErrorCode: String, Codable, Sendable {
|
|||||||
case unavailable = "UNAVAILABLE"
|
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 struct ConnectParams: Codable, Sendable {
|
||||||
public let minprotocol: Int
|
public let minprotocol: Int
|
||||||
public let maxprotocol: 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 struct NodePendingDrainParams: Codable, Sendable {
|
||||||
public let maxitems: Int?
|
public let maxitems: Int?
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ public enum ErrorCode: String, Codable, Sendable {
|
|||||||
case unavailable = "UNAVAILABLE"
|
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 struct ConnectParams: Codable, Sendable {
|
||||||
public let minprotocol: Int
|
public let minprotocol: Int
|
||||||
public let maxprotocol: 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 struct NodePendingDrainParams: Codable, Sendable {
|
||||||
public let maxitems: Int?
|
public let maxitems: Int?
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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)
|
## Auto-approval (macOS app)
|
||||||
|
|
||||||
The macOS app can optionally attempt a **silent approval** when:
|
The macOS app can optionally attempt a **silent approval** when:
|
||||||
|
|||||||
@@ -255,6 +255,40 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
- `system-presence` returns entries keyed by device identity.
|
- `system-presence` returns entries keyed by device identity.
|
||||||
- Presence entries include `deviceId`, `roles`, and `scopes` so UIs can show a single row per device
|
- 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**.
|
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
|
## Broadcast event scoping
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
Compatibility note:
|
||||||
|
|
||||||
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
|
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ function camelCase(input: string) {
|
|||||||
|
|
||||||
function safeName(name: string) {
|
function safeName(name: string) {
|
||||||
const cc = camelCase(name.replace(/-/g, "_"));
|
const cc = camelCase(name.replace(/-/g, "_"));
|
||||||
|
if (/^\d/.test(cc)) {
|
||||||
|
return `_${cc}`;
|
||||||
|
}
|
||||||
if (reserved.has(cc)) {
|
if (reserved.has(cc)) {
|
||||||
return `_${cc}`;
|
return `_${cc}`;
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,16 @@ function swiftType(schema: JsonSchema, required: boolean, allowStructuralNamed =
|
|||||||
return isOptional ? `${base}?` : base;
|
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 {
|
function emitStruct(name: string, schema: JsonSchema): string {
|
||||||
const props = schema.properties ?? {};
|
const props = schema.properties ?? {};
|
||||||
const required = new Set(schema.required ?? []);
|
const required = new Set(schema.required ?? []);
|
||||||
@@ -262,7 +275,16 @@ async function generate() {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
parts.push(header);
|
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) {
|
for (const [name, schema] of definitions) {
|
||||||
if (name === "GatewayFrame") {
|
if (name === "GatewayFrame") {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ describe("gateway/node-catalog", () => {
|
|||||||
pathEnv: "/usr/bin:/bin",
|
pathEnv: "/usr/bin:/bin",
|
||||||
approvedAtMs: 100,
|
approvedAtMs: 100,
|
||||||
connectedAtMs,
|
connectedAtMs,
|
||||||
|
lastSeenAtMs: connectedAtMs,
|
||||||
|
lastSeenReason: "connect",
|
||||||
paired: true,
|
paired: true,
|
||||||
connected: true,
|
connected: true,
|
||||||
}),
|
}),
|
||||||
@@ -171,6 +173,8 @@ describe("gateway/node-catalog", () => {
|
|||||||
platform: "darwin",
|
platform: "darwin",
|
||||||
caps: ["system"],
|
caps: ["system"],
|
||||||
commands: ["system.run"],
|
commands: ["system.run"],
|
||||||
|
lastSeenAtMs: 456,
|
||||||
|
lastSeenReason: "silent_push",
|
||||||
createdAtMs: 1,
|
createdAtMs: 1,
|
||||||
approvedAtMs: 123,
|
approvedAtMs: 123,
|
||||||
},
|
},
|
||||||
@@ -193,12 +197,62 @@ describe("gateway/node-catalog", () => {
|
|||||||
caps: ["system"],
|
caps: ["system"],
|
||||||
commands: ["system.run"],
|
commands: ["system.run"],
|
||||||
approvedAtMs: 123,
|
approvedAtMs: 123,
|
||||||
|
lastSeenAtMs: 456,
|
||||||
|
lastSeenReason: "silent_push",
|
||||||
paired: true,
|
paired: true,
|
||||||
connected: false,
|
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", () => {
|
it("prefers the live command surface for connected nodes", () => {
|
||||||
const catalog = createKnownNodeCatalog({
|
const catalog = createKnownNodeCatalog({
|
||||||
pairedDevices: [],
|
pairedDevices: [],
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type KnownNodeDevicePairingSource = {
|
|||||||
clientMode?: string;
|
clientMode?: string;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
approvedAtMs?: number;
|
approvedAtMs?: number;
|
||||||
|
lastSeenAtMs?: number;
|
||||||
|
lastSeenReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KnownNodeApprovedSource = {
|
export type KnownNodeApprovedSource = {
|
||||||
@@ -28,6 +30,9 @@ export type KnownNodeApprovedSource = {
|
|||||||
commands: string[];
|
commands: string[];
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
approvedAtMs?: number;
|
approvedAtMs?: number;
|
||||||
|
lastConnectedAtMs?: number;
|
||||||
|
lastSeenAtMs?: number;
|
||||||
|
lastSeenReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KnownNodeEntry = {
|
export type KnownNodeEntry = {
|
||||||
@@ -67,6 +72,8 @@ function buildDevicePairingSource(entry: PairedDevice): KnownNodeDevicePairingSo
|
|||||||
clientMode: entry.clientMode,
|
clientMode: entry.clientMode,
|
||||||
remoteIp: entry.remoteIp,
|
remoteIp: entry.remoteIp,
|
||||||
approvedAtMs: entry.approvedAtMs,
|
approvedAtMs: entry.approvedAtMs,
|
||||||
|
lastSeenAtMs: entry.lastSeenAtMs,
|
||||||
|
lastSeenReason: entry.lastSeenReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +92,36 @@ function buildApprovedNodeSource(entry: NodePairingPairedNode): KnownNodeApprove
|
|||||||
commands: entry.commands ?? [],
|
commands: entry.commands ?? [],
|
||||||
permissions: entry.permissions,
|
permissions: entry.permissions,
|
||||||
approvedAtMs: entry.approvedAtMs,
|
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;
|
live?: NodeSession;
|
||||||
}): NodeListNode {
|
}): NodeListNode {
|
||||||
const { nodeId, devicePairing, nodePairing, live } = entry;
|
const { nodeId, devicePairing, nodePairing, live } = entry;
|
||||||
|
const lastSeen = resolveEffectiveLastSeen({ live, devicePairing, nodePairing });
|
||||||
return {
|
return {
|
||||||
nodeId,
|
nodeId,
|
||||||
displayName: live?.displayName ?? nodePairing?.displayName ?? devicePairing?.displayName,
|
displayName: live?.displayName ?? nodePairing?.displayName ?? devicePairing?.displayName,
|
||||||
@@ -114,6 +152,8 @@ function buildEffectiveKnownNode(entry: {
|
|||||||
pathEnv: live?.pathEnv,
|
pathEnv: live?.pathEnv,
|
||||||
permissions: live?.permissions ?? nodePairing?.permissions,
|
permissions: live?.permissions ?? nodePairing?.permissions,
|
||||||
connectedAtMs: live?.connectedAtMs,
|
connectedAtMs: live?.connectedAtMs,
|
||||||
|
lastSeenAtMs: lastSeen.lastSeenAtMs,
|
||||||
|
lastSeenReason: lastSeen.lastSeenReason,
|
||||||
approvedAtMs: nodePairing?.approvedAtMs ?? devicePairing?.approvedAtMs,
|
approvedAtMs: nodePairing?.approvedAtMs ?? devicePairing?.approvedAtMs,
|
||||||
paired: Boolean(devicePairing ?? nodePairing),
|
paired: Boolean(devicePairing ?? nodePairing),
|
||||||
connected: Boolean(live),
|
connected: Boolean(live),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { TALK_TEST_PROVIDER_ID } from "../../test-utils/talk-test-provider.js";
|
|||||||
import {
|
import {
|
||||||
formatValidationErrors,
|
formatValidationErrors,
|
||||||
validateModelsListParams,
|
validateModelsListParams,
|
||||||
|
validateNodeEventResult,
|
||||||
|
validateNodePresenceAlivePayload,
|
||||||
validateTalkConfigResult,
|
validateTalkConfigResult,
|
||||||
validateTalkRealtimeSessionParams,
|
validateTalkRealtimeSessionParams,
|
||||||
validateWakeParams,
|
validateWakeParams,
|
||||||
@@ -190,3 +192,43 @@ describe("validateModelsListParams", () => {
|
|||||||
expect(validateModelsListParams({ view: "configured", provider: "minimax" })).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ import {
|
|||||||
NodeDescribeParamsSchema,
|
NodeDescribeParamsSchema,
|
||||||
type NodeEventParams,
|
type NodeEventParams,
|
||||||
NodeEventParamsSchema,
|
NodeEventParamsSchema,
|
||||||
|
type NodeEventResult,
|
||||||
|
NodeEventResultSchema,
|
||||||
type NodePendingDrainParams,
|
type NodePendingDrainParams,
|
||||||
NodePendingDrainParamsSchema,
|
NodePendingDrainParamsSchema,
|
||||||
type NodePendingDrainResult,
|
type NodePendingDrainResult,
|
||||||
@@ -185,6 +187,10 @@ import {
|
|||||||
NodePendingEnqueueParamsSchema,
|
NodePendingEnqueueParamsSchema,
|
||||||
type NodePendingEnqueueResult,
|
type NodePendingEnqueueResult,
|
||||||
NodePendingEnqueueResultSchema,
|
NodePendingEnqueueResultSchema,
|
||||||
|
type NodePresenceAlivePayload,
|
||||||
|
NodePresenceAlivePayloadSchema,
|
||||||
|
type NodePresenceAliveReason,
|
||||||
|
NodePresenceAliveReasonSchema,
|
||||||
type NodeInvokeParams,
|
type NodeInvokeParams,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
type NodeInvokeResultParams,
|
type NodeInvokeResultParams,
|
||||||
@@ -388,6 +394,10 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
|
|||||||
NodeInvokeResultParamsSchema,
|
NodeInvokeResultParamsSchema,
|
||||||
);
|
);
|
||||||
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
||||||
|
export const validateNodeEventResult = ajv.compile<NodeEventResult>(NodeEventResultSchema);
|
||||||
|
export const validateNodePresenceAlivePayload = ajv.compile<NodePresenceAlivePayload>(
|
||||||
|
NodePresenceAlivePayloadSchema,
|
||||||
|
);
|
||||||
export const validateNodePendingDrainParams = ajv.compile<NodePendingDrainParams>(
|
export const validateNodePendingDrainParams = ajv.compile<NodePendingDrainParams>(
|
||||||
NodePendingDrainParamsSchema,
|
NodePendingDrainParamsSchema,
|
||||||
);
|
);
|
||||||
@@ -651,6 +661,9 @@ export {
|
|||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
NodePendingAckParamsSchema,
|
NodePendingAckParamsSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
|
NodeEventResultSchema,
|
||||||
|
NodePresenceAlivePayloadSchema,
|
||||||
|
NodePresenceAliveReasonSchema,
|
||||||
NodePendingDrainParamsSchema,
|
NodePendingDrainParamsSchema,
|
||||||
NodePendingDrainResultSchema,
|
NodePendingDrainResultSchema,
|
||||||
NodePendingEnqueueParamsSchema,
|
NodePendingEnqueueParamsSchema,
|
||||||
@@ -857,6 +870,9 @@ export type {
|
|||||||
NodeInvokeParams,
|
NodeInvokeParams,
|
||||||
NodeInvokeResultParams,
|
NodeInvokeResultParams,
|
||||||
NodeEventParams,
|
NodeEventParams,
|
||||||
|
NodeEventResult,
|
||||||
|
NodePresenceAlivePayload,
|
||||||
|
NodePresenceAliveReason,
|
||||||
NodePendingDrainParams,
|
NodePendingDrainParams,
|
||||||
NodePendingDrainResult,
|
NodePendingDrainResult,
|
||||||
NodePendingEnqueueParams,
|
NodePendingEnqueueParams,
|
||||||
|
|||||||
@@ -9,6 +9,41 @@ const NodePendingWorkPrioritySchema = Type.String({
|
|||||||
enum: ["normal", "high"],
|
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(
|
export const NodePairRequestParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
nodeId: NonEmptyString,
|
nodeId: NonEmptyString,
|
||||||
|
|||||||
@@ -140,10 +140,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
NodeDescribeParamsSchema,
|
NodeDescribeParamsSchema,
|
||||||
NodeEventParamsSchema,
|
NodeEventParamsSchema,
|
||||||
|
NodeEventResultSchema,
|
||||||
NodePendingDrainParamsSchema,
|
NodePendingDrainParamsSchema,
|
||||||
NodePendingDrainResultSchema,
|
NodePendingDrainResultSchema,
|
||||||
NodePendingEnqueueParamsSchema,
|
NodePendingEnqueueParamsSchema,
|
||||||
NodePendingEnqueueResultSchema,
|
NodePendingEnqueueResultSchema,
|
||||||
|
NodePresenceAlivePayloadSchema,
|
||||||
|
NodePresenceAliveReasonSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
NodeInvokeResultParamsSchema,
|
NodeInvokeResultParamsSchema,
|
||||||
NodeInvokeRequestEventSchema,
|
NodeInvokeRequestEventSchema,
|
||||||
@@ -244,6 +247,9 @@ export const ProtocolSchemas = {
|
|||||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||||
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
||||||
NodeEventParams: NodeEventParamsSchema,
|
NodeEventParams: NodeEventParamsSchema,
|
||||||
|
NodeEventResult: NodeEventResultSchema,
|
||||||
|
NodePresenceAlivePayload: NodePresenceAlivePayloadSchema,
|
||||||
|
NodePresenceAliveReason: NodePresenceAliveReasonSchema,
|
||||||
NodePendingDrainParams: NodePendingDrainParamsSchema,
|
NodePendingDrainParams: NodePendingDrainParamsSchema,
|
||||||
NodePendingDrainResult: NodePendingDrainResultSchema,
|
NodePendingDrainResult: NodePendingDrainResultSchema,
|
||||||
NodePendingEnqueueParams: NodePendingEnqueueParamsSchema,
|
NodePendingEnqueueParams: NodePendingEnqueueParamsSchema,
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export type NodeDescribeParams = SchemaType<"NodeDescribeParams">;
|
|||||||
export type NodeInvokeParams = SchemaType<"NodeInvokeParams">;
|
export type NodeInvokeParams = SchemaType<"NodeInvokeParams">;
|
||||||
export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">;
|
export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">;
|
||||||
export type NodeEventParams = SchemaType<"NodeEventParams">;
|
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 NodePendingDrainParams = SchemaType<"NodePendingDrainParams">;
|
||||||
export type NodePendingDrainResult = SchemaType<"NodePendingDrainResult">;
|
export type NodePendingDrainResult = SchemaType<"NodePendingDrainResult">;
|
||||||
export type NodePendingEnqueueParams = SchemaType<"NodePendingEnqueueParams">;
|
export type NodePendingEnqueueParams = SchemaType<"NodePendingEnqueueParams">;
|
||||||
|
|||||||
@@ -1164,11 +1164,16 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||||
logGateway: { warn: context.logGateway.warn },
|
logGateway: { warn: context.logGateway.warn },
|
||||||
};
|
};
|
||||||
await handleNodeEvent(nodeContext, nodeId, {
|
const result = await handleNodeEvent(
|
||||||
event: p.event,
|
nodeContext,
|
||||||
payloadJSON,
|
nodeId,
|
||||||
});
|
{
|
||||||
respond(true, { ok: true }, undefined);
|
event: p.event,
|
||||||
|
payloadJSON,
|
||||||
|
},
|
||||||
|
{ deviceId: client?.connect?.device?.id },
|
||||||
|
);
|
||||||
|
respond(true, result ?? { ok: true }, undefined);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const sanitizeInboundSystemTagsMock = vi.hoisted(() =>
|
|||||||
.replace(/^(\s*)System:(?=\s|$)/gim, "$1System (untrusted):"),
|
.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(() => ({
|
const runtimeMocks = vi.hoisted(() => ({
|
||||||
agentCommandFromIngress: ingressAgentCommandMock,
|
agentCommandFromIngress: ingressAgentCommandMock,
|
||||||
@@ -132,11 +134,21 @@ const runtimeMocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./server-node-events.runtime.js", () => runtimeMocks);
|
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 { CliDeps } from "../cli/deps.js";
|
||||||
import type { HealthSummary } from "../commands/health.js";
|
import type { HealthSummary } from "../commands/health.js";
|
||||||
import type { NodeEventContext } from "./server-node-events-types.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 enqueueSystemEventMock = runtimeMocks.enqueueSystemEvent;
|
||||||
const requestHeartbeatNowMock = runtimeMocks.requestHeartbeatNow;
|
const requestHeartbeatNowMock = runtimeMocks.requestHeartbeatNow;
|
||||||
@@ -181,6 +193,10 @@ describe("node exec events", () => {
|
|||||||
normalizeChannelIdVi.mockClear();
|
normalizeChannelIdVi.mockClear();
|
||||||
normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null);
|
normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null);
|
||||||
sanitizeInboundSystemTagsMock.mockClear();
|
sanitizeInboundSystemTagsMock.mockClear();
|
||||||
|
updatePairedDeviceMetadataMock.mockClear();
|
||||||
|
updatePairedDeviceMetadataMock.mockResolvedValue(true);
|
||||||
|
updatePairedNodeMetadataMock.mockClear();
|
||||||
|
updatePairedNodeMetadataMock.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enqueues exec.started events", async () => {
|
it("enqueues exec.started events", async () => {
|
||||||
@@ -998,4 +1014,139 @@ describe("agent request events", () => {
|
|||||||
expect(agentCommandMock).not.toHaveBeenCalled();
|
expect(agentCommandMock).not.toHaveBeenCalled();
|
||||||
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/attachment parse failed.*non-image/i));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||||
|
import { updatePairedDeviceMetadata } from "../infra/device-pairing.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { updatePairedNodeMetadata } from "../infra/node-pairing.js";
|
||||||
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
|
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
|
||||||
|
import {
|
||||||
|
NODE_PRESENCE_ALIVE_EVENT,
|
||||||
|
normalizeNodePresenceAliveReason,
|
||||||
|
} from "../shared/node-presence.js";
|
||||||
import {
|
import {
|
||||||
normalizeLowercaseStringOrEmpty,
|
normalizeLowercaseStringOrEmpty,
|
||||||
normalizeOptionalString,
|
normalizeOptionalString,
|
||||||
@@ -42,9 +48,19 @@ const VOICE_TRANSCRIPT_DEDUPE_WINDOW_MS = 1500;
|
|||||||
const MAX_RECENT_VOICE_TRANSCRIPTS = 200;
|
const MAX_RECENT_VOICE_TRANSCRIPTS = 200;
|
||||||
const EXEC_FINISHED_RUN_DEDUPE_WINDOW_MS = 10 * 60 * 1000;
|
const EXEC_FINISHED_RUN_DEDUPE_WINDOW_MS = 10 * 60 * 1000;
|
||||||
const MAX_RECENT_EXEC_FINISHED_RUNS = 2000;
|
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<string, { fingerprint: string; ts: number }>();
|
const recentVoiceTranscripts = new Map<string, { fingerprint: string; ts: number }>();
|
||||||
const recentExecFinishedRuns = new Map<string, number>();
|
const recentExecFinishedRuns = new Map<string, number>();
|
||||||
|
const recentNodePresencePersistAt = new Map<string, number>();
|
||||||
|
|
||||||
|
export type NodeEventHandleResult = {
|
||||||
|
ok: true;
|
||||||
|
event: string;
|
||||||
|
handled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeFiniteInteger(value: unknown): number | null {
|
function normalizeFiniteInteger(value: unknown): number | null {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : null;
|
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : null;
|
||||||
@@ -157,9 +173,39 @@ function shouldDropDuplicateExecFinished(params: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneBoundedTimestampMap(
|
||||||
|
map: Map<string, number>,
|
||||||
|
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() {
|
export function resetNodeEventDeduplicationForTests() {
|
||||||
recentVoiceTranscripts.clear();
|
recentVoiceTranscripts.clear();
|
||||||
recentExecFinishedRuns.clear();
|
recentExecFinishedRuns.clear();
|
||||||
|
recentNodePresencePersistAt.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentNodePresencePersistCountForTests() {
|
||||||
|
return recentNodePresencePersistAt.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compactExecEventOutput(raw: string) {
|
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<NodeEventHandleResult | undefined> => {
|
||||||
switch (evt.event) {
|
switch (evt.event) {
|
||||||
case "voice.transcript": {
|
case "voice.transcript": {
|
||||||
const obj = parsePayloadObject(evt.payloadJSON);
|
const obj = parsePayloadObject(evt.payloadJSON);
|
||||||
if (!obj) {
|
if (!obj) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const text = normalizeOptionalString(obj.text) ?? "";
|
const text = normalizeOptionalString(obj.text) ?? "";
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (text.length > 20_000) {
|
if (text.length > 20_000) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? "";
|
const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? "";
|
||||||
const cfg = getRuntimeConfig();
|
const cfg = getRuntimeConfig();
|
||||||
@@ -332,7 +383,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fingerprint = resolveVoiceTranscriptFingerprint(obj, text);
|
const fingerprint = resolveVoiceTranscriptFingerprint(obj, text);
|
||||||
if (shouldDropDuplicateVoiceTranscript({ sessionKey: canonicalKey, fingerprint, now })) {
|
if (shouldDropDuplicateVoiceTranscript({ sessionKey: canonicalKey, fingerprint, now })) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const sessionId = entry?.sessionId ?? randomUUID();
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
queueSessionStoreTouch({
|
queueSessionStoreTouch({
|
||||||
@@ -376,11 +427,11 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
});
|
});
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
case "agent.request": {
|
case "agent.request": {
|
||||||
if (!evt.payloadJSON) {
|
if (!evt.payloadJSON) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
type AgentDeepLink = {
|
type AgentDeepLink = {
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -405,7 +456,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
try {
|
try {
|
||||||
link = JSON.parse(evt.payloadJSON) as AgentDeepLink;
|
link = JSON.parse(evt.payloadJSON) as AgentDeepLink;
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
|
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 images: Array<{ type: "image"; data: string; mimeType: string }> = [];
|
||||||
let imageOrder: PromptImageOrderEntry[] = [];
|
let imageOrder: PromptImageOrderEntry[] = [];
|
||||||
if (!message && normalizedAttachments.length === 0) {
|
if (!message && normalizedAttachments.length === 0) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (message.length > 20_000) {
|
if (message.length > 20_000) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (normalizedAttachments.length > 0) {
|
if (normalizedAttachments.length > 0) {
|
||||||
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
|
const sessionAgentId = resolveSessionAgentId({ sessionKey, config: cfg });
|
||||||
@@ -461,16 +512,16 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logGateway.warn(`agent.request attachment parse failed: ${formatErrorMessage(err)}`);
|
ctx.logGateway.warn(`agent.request attachment parse failed: ${formatErrorMessage(err)}`);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!message && images.length === 0) {
|
if (!message && images.length === 0) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelRaw = normalizeOptionalString(link?.channel) ?? "";
|
const channelRaw = normalizeOptionalString(link?.channel) ?? "";
|
||||||
@@ -548,22 +599,22 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
});
|
});
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
case "notifications.changed": {
|
case "notifications.changed": {
|
||||||
const obj = parsePayloadObject(evt.payloadJSON);
|
const obj = parsePayloadObject(evt.payloadJSON);
|
||||||
if (!obj) {
|
if (!obj) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const change = normalizeOptionalString(obj.change)
|
const change = normalizeOptionalString(obj.change)
|
||||||
? normalizeLowercaseStringOrEmpty(obj.change)
|
? normalizeLowercaseStringOrEmpty(obj.change)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (change !== "posted" && change !== "removed") {
|
if (change !== "posted" && change !== "removed") {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const keyRaw = normalizeOptionalString(obj.key);
|
const keyRaw = normalizeOptionalString(obj.key);
|
||||||
if (!keyRaw) {
|
if (!keyRaw) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const key = sanitizeInboundSystemTags(keyRaw);
|
const key = sanitizeInboundSystemTags(keyRaw);
|
||||||
const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`;
|
const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`;
|
||||||
@@ -597,40 +648,40 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
if (queued) {
|
if (queued) {
|
||||||
requestHeartbeatNow({ reason: "notifications-event", sessionKey });
|
requestHeartbeatNow({ reason: "notifications-event", sessionKey });
|
||||||
}
|
}
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
case "chat.subscribe": {
|
case "chat.subscribe": {
|
||||||
if (!evt.payloadJSON) {
|
if (!evt.payloadJSON) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON);
|
const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON);
|
||||||
if (!sessionKey) {
|
if (!sessionKey) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
ctx.nodeSubscribe(nodeId, sessionKey);
|
ctx.nodeSubscribe(nodeId, sessionKey);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
case "chat.unsubscribe": {
|
case "chat.unsubscribe": {
|
||||||
if (!evt.payloadJSON) {
|
if (!evt.payloadJSON) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON);
|
const sessionKey = parseSessionKeyFromPayloadJSON(evt.payloadJSON);
|
||||||
if (!sessionKey) {
|
if (!sessionKey) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
ctx.nodeUnsubscribe(nodeId, sessionKey);
|
ctx.nodeUnsubscribe(nodeId, sessionKey);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
case "exec.started":
|
case "exec.started":
|
||||||
case "exec.finished":
|
case "exec.finished":
|
||||||
case "exec.denied": {
|
case "exec.denied": {
|
||||||
const obj = parsePayloadObject(evt.payloadJSON);
|
const obj = parsePayloadObject(evt.payloadJSON);
|
||||||
if (!obj) {
|
if (!obj) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`;
|
const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? `node-${nodeId}`;
|
||||||
if (!sessionKeyRaw) {
|
if (!sessionKeyRaw) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const { canonicalKey: sessionKey } = loadSessionEntry(sessionKeyRaw);
|
const { canonicalKey: sessionKey } = loadSessionEntry(sessionKeyRaw);
|
||||||
|
|
||||||
@@ -639,10 +690,10 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
const cfg = getRuntimeConfig();
|
const cfg = getRuntimeConfig();
|
||||||
const notifyOnExit = cfg.tools?.exec?.notifyOnExit !== false;
|
const notifyOnExit = cfg.tools?.exec?.notifyOnExit !== false;
|
||||||
if (!notifyOnExit) {
|
if (!notifyOnExit) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (obj.suppressNotifyOnExit === true) {
|
if (obj.suppressNotifyOnExit === true) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runId = normalizeOptionalString(obj.runId) ?? "";
|
const runId = normalizeOptionalString(obj.runId) ?? "";
|
||||||
@@ -666,7 +717,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
const compactOutput = compactExecEventOutput(output);
|
const compactOutput = compactExecEventOutput(output);
|
||||||
const shouldNotify = timedOut || exitCode !== 0 || compactOutput.length > 0;
|
const shouldNotify = timedOut || exitCode !== 0 || compactOutput.length > 0;
|
||||||
if (!shouldNotify) {
|
if (!shouldNotify) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
runId &&
|
runId &&
|
||||||
@@ -676,7 +727,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`;
|
text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`;
|
||||||
if (compactOutput) {
|
if (compactOutput) {
|
||||||
@@ -702,12 +753,12 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event", coalesceMs: 0 }),
|
scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event", coalesceMs: 0 }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
case "push.apns.register": {
|
case "push.apns.register": {
|
||||||
const obj = parsePayloadObject(evt.payloadJSON);
|
const obj = parsePayloadObject(evt.payloadJSON);
|
||||||
if (!obj) {
|
if (!obj) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const transport = normalizeLowercaseStringOrEmpty(obj.transport) || "direct";
|
const transport = normalizeLowercaseStringOrEmpty(obj.transport) || "direct";
|
||||||
const topic = typeof obj.topic === "string" ? obj.topic : "";
|
const topic = typeof obj.topic === "string" ? obj.topic : "";
|
||||||
@@ -720,7 +771,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
ctx.logGateway.warn(
|
ctx.logGateway.warn(
|
||||||
`push relay register rejected node=${nodeId}: gateway identity mismatch`,
|
`push relay register rejected node=${nodeId}: gateway identity mismatch`,
|
||||||
);
|
);
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
await registerApnsRegistration({
|
await registerApnsRegistration({
|
||||||
nodeId,
|
nodeId,
|
||||||
@@ -745,9 +796,51 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(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:
|
default:
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -554,6 +554,33 @@ describe("device pairing tokens", () => {
|
|||||||
expect(paired?.tokens?.operator).toBeUndefined();
|
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 () => {
|
test("generates base64url device tokens with 256-bit entropy output length", async () => {
|
||||||
const baseDir = await makeDevicePairingDir();
|
const baseDir = await makeDevicePairingDir();
|
||||||
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||||
|
|||||||
@@ -89,11 +89,13 @@ export type PairedDevice = {
|
|||||||
tokens?: Record<string, DeviceAuthToken>;
|
tokens?: Record<string, DeviceAuthToken>;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
approvedAtMs: number;
|
approvedAtMs: number;
|
||||||
|
lastSeenAtMs?: number;
|
||||||
|
lastSeenReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PairedDeviceMetadataPatch = Pick<
|
export type PairedDeviceMetadataPatch = Pick<
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
"displayName" | "clientId" | "clientMode" | "remoteIp"
|
"displayName" | "clientId" | "clientMode" | "remoteIp" | "lastSeenAtMs" | "lastSeenReason"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type DevicePairingList = {
|
export type DevicePairingList = {
|
||||||
@@ -793,13 +795,13 @@ export async function updatePairedDeviceMetadata(
|
|||||||
deviceId: string,
|
deviceId: string,
|
||||||
patch: Partial<PairedDeviceMetadataPatch>,
|
patch: Partial<PairedDeviceMetadataPatch>,
|
||||||
baseDir?: string,
|
baseDir?: string,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
return await withLock(async () => {
|
return await withLock(async () => {
|
||||||
const state = await loadState(baseDir);
|
const state = await loadState(baseDir);
|
||||||
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
||||||
const existing = state.pairedByDeviceId[normalizedDeviceId];
|
const existing = state.pairedByDeviceId[normalizedDeviceId];
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const next = { ...existing };
|
const next = { ...existing };
|
||||||
if ("displayName" in patch) {
|
if ("displayName" in patch) {
|
||||||
@@ -814,8 +816,15 @@ export async function updatePairedDeviceMetadata(
|
|||||||
if ("remoteIp" in patch) {
|
if ("remoteIp" in patch) {
|
||||||
next.remoteIp = patch.remoteIp;
|
next.remoteIp = patch.remoteIp;
|
||||||
}
|
}
|
||||||
|
if ("lastSeenAtMs" in patch) {
|
||||||
|
next.lastSeenAtMs = patch.lastSeenAtMs;
|
||||||
|
}
|
||||||
|
if ("lastSeenReason" in patch) {
|
||||||
|
next.lastSeenReason = patch.lastSeenReason;
|
||||||
|
}
|
||||||
state.pairedByDeviceId[normalizedDeviceId] = next;
|
state.pairedByDeviceId[normalizedDeviceId] = next;
|
||||||
await persistState(state, baseDir, "paired");
|
await persistState(state, baseDir, "paired");
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
listNodePairing,
|
listNodePairing,
|
||||||
removePairedNode,
|
removePairedNode,
|
||||||
requestNodePairing,
|
requestNodePairing,
|
||||||
|
updatePairedNodeMetadata,
|
||||||
verifyNodeToken,
|
verifyNodeToken,
|
||||||
} from "./node-pairing.js";
|
} from "./node-pairing.js";
|
||||||
import { resolvePairingPaths } from "./pairing-files.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}");
|
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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type NodePairingPairedNode = NodeApprovedSurface & {
|
|||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
approvedAtMs: number;
|
approvedAtMs: number;
|
||||||
lastConnectedAtMs?: number;
|
lastConnectedAtMs?: number;
|
||||||
|
lastSeenAtMs?: number;
|
||||||
|
lastSeenReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodePairingList = {
|
export type NodePairingList = {
|
||||||
@@ -321,13 +323,13 @@ export async function updatePairedNodeMetadata(
|
|||||||
nodeId: string,
|
nodeId: string,
|
||||||
patch: Partial<Omit<NodePairingPairedNode, "nodeId" | "token" | "createdAtMs" | "approvedAtMs">>,
|
patch: Partial<Omit<NodePairingPairedNode, "nodeId" | "token" | "createdAtMs" | "approvedAtMs">>,
|
||||||
baseDir?: string,
|
baseDir?: string,
|
||||||
) {
|
): Promise<boolean> {
|
||||||
await withLock(async () => {
|
return await withLock(async () => {
|
||||||
const state = await loadState(baseDir);
|
const state = await loadState(baseDir);
|
||||||
const normalized = normalizeNodeId(nodeId);
|
const normalized = normalizeNodeId(nodeId);
|
||||||
const existing = state.pairedByNodeId[normalized];
|
const existing = state.pairedByNodeId[normalized];
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next: NodePairingPairedNode = {
|
const next: NodePairingPairedNode = {
|
||||||
@@ -345,10 +347,13 @@ export async function updatePairedNodeMetadata(
|
|||||||
bins: patch.bins ?? existing.bins,
|
bins: patch.bins ?? existing.bins,
|
||||||
permissions: patch.permissions ?? existing.permissions,
|
permissions: patch.permissions ?? existing.permissions,
|
||||||
lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs,
|
lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs,
|
||||||
|
lastSeenAtMs: patch.lastSeenAtMs ?? existing.lastSeenAtMs,
|
||||||
|
lastSeenReason: patch.lastSeenReason ?? existing.lastSeenReason,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.pairedByNodeId[normalized] = next;
|
state.pairedByNodeId[normalized] = next;
|
||||||
await persistState(state, baseDir);
|
await persistState(state, baseDir);
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type NodeListNode = {
|
|||||||
paired?: boolean;
|
paired?: boolean;
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
connectedAtMs?: number;
|
connectedAtMs?: number;
|
||||||
|
lastSeenAtMs?: number;
|
||||||
|
lastSeenReason?: string;
|
||||||
approvedAtMs?: number;
|
approvedAtMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,6 +49,8 @@ export type PairedNode = {
|
|||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
approvedAtMs?: number;
|
approvedAtMs?: number;
|
||||||
lastConnectedAtMs?: number;
|
lastConnectedAtMs?: number;
|
||||||
|
lastSeenAtMs?: number;
|
||||||
|
lastSeenReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PairingList = {
|
export type PairingList = {
|
||||||
|
|||||||
24
src/shared/node-presence.ts
Normal file
24
src/shared/node-presence.ts
Normal file
@@ -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<string>(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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user