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:
Peter Steinberger
2026-04-28 08:10:35 +01:00
committed by GitHub
parent d525d6486d
commit bdba90a20b
27 changed files with 1082 additions and 76 deletions

View File

@@ -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.

View File

@@ -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")
}
}
}

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

View File

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

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

View File

@@ -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?

View File

@@ -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?

View File

@@ -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:

View File

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

View File

@@ -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.

View File

@@ -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;

View File

@@ -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: [],

View File

@@ -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<string, boolean>;
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),

View File

@@ -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);
});
});

View File

@@ -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<NodeInvokeResultParams
NodeInvokeResultParamsSchema,
);
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>(
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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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">;

View File

@@ -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);
});
},
};

View File

@@ -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);
});
});

View File

@@ -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<string, { fingerprint: string; ts: 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 {
return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : null;
@@ -157,9 +173,39 @@ function shouldDropDuplicateExecFinished(params: {
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() {
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<NodeEventHandleResult | undefined> => {
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;
}
};

View File

@@ -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"]);

View File

@@ -89,11 +89,13 @@ export type PairedDevice = {
tokens?: Record<string, DeviceAuthToken>;
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<PairedDeviceMetadataPatch>,
baseDir?: string,
): Promise<void> {
): Promise<boolean> {
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;
});
}

View File

@@ -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",
}),
);
});
});
});

View File

@@ -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<Omit<NodePairingPairedNode, "nodeId" | "token" | "createdAtMs" | "approvedAtMs">>,
baseDir?: string,
) {
await withLock(async () => {
): Promise<boolean> {
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;
});
}

View File

@@ -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 = {

View 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";
}