Skip to content

feat: add authenticated iOS background presence beacon#73330

Merged
steipete merged 4 commits intomainfrom
fix/ios-background-presence-beacon
Apr 28, 2026
Merged

feat: add authenticated iOS background presence beacon#73330
steipete merged 4 commits intomainfrom
fix/ios-background-presence-beacon

Conversation

@steipete
Copy link
Copy Markdown
Contributor

Summary

  • add authenticated node.presence.alive over existing node.event for iOS background wakes
  • persist durable last-seen metadata only for approved node/device identities, with a closed reason enum and bounded throttling
  • expose lastSeenAtMs / lastSeenReason in node.list, update protocol docs, and add regression coverage

Carries forward the useful behavior from #63123 with a tighter protocol/persistence boundary. Thanks @ngutman.

Tests

  • pnpm test src/gateway/server-node-events.test.ts src/gateway/node-catalog.test.ts src/infra/node-pairing.test.ts src/infra/device-pairing.test.ts src/gateway/protocol/index.test.ts
  • pnpm lint:swift
  • xcodebuild build -project apps/ios/OpenClaw.xcodeproj -scheme OpenClaw -destination 'platform=iOS Simulator,name=iPhone 17'
  • xcodebuild test -project apps/ios/OpenClaw.xcodeproj -scheme OpenClaw -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:OpenClawTests/BackgroundAliveBeaconTests ran the focused Swift Testing suite: 5/5 passed; local xcodebuild hung during simulator teardown after reporting success, so it was killed
  • Testbox pnpm tsgo:core && pnpm tsgo:core:test && pnpm lint:core && pnpm check:runtime-sidecar-loaders && pnpm check:import-cycles && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope

Note: Testbox pnpm check:changed reached lint:apps and stopped because the Linux image has no swiftlint; local pnpm lint:swift covered the app lint lane.

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation app: ios App: ios app: web-ui App: web-ui gateway Gateway runtime labels Apr 28, 2026
@cursor
Copy link
Copy Markdown

cursor Bot commented Apr 28, 2026

PR Summary

Medium Risk
Introduces a new protocol event and persists new pairing metadata fields, so client/server compatibility and throttling/identity edge cases could affect presence reporting despite the tightened authentication boundary.

Overview
Adds an authenticated background presence mechanism: iOS now publishes node.presence.alive via node.event during background wakes (silent push/refresh/location), and treats the wake as successful only when the gateway returns handled: true (with local throttling of recent successes).

Gateway handling for node.presence.alive now validates/normalizes a closed trigger enum, requires an authenticated deviceId, persists lastSeenAtMs/lastSeenReason into paired node + paired device metadata, and rate-limits persistence per device with a bounded in-memory map; other node.event calls continue returning the legacy { ok: true } ack.

node.list is extended to surface lastSeenAtMs/lastSeenReason (resolved from live connection vs paired metadata), Swift protocol generation is updated to emit string enums, shared/macOS Swift protocol models gain NodeEventResult/NodePresenceAlivePayload, and docs/tests are updated accordingly.

Reviewed by Cursor Bugbot for commit d5c1900. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 28, 2026

Greptile Summary

This PR adds an authenticated node.presence.alive background beacon for iOS, letting silent-push, background-refresh, and significant-location wakes record durable lastSeenAtMs/lastSeenReason on paired node/device metadata without treating the node as connected. The gateway side adds schema types, per-device in-process throttling, and persistence via the existing pairing stores; node.list surfaces the resolved last-seen across live and durable sources. Test coverage is solid across the Swift, gateway, and infra layers.

Confidence Score: 4/5

Safe to merge; all findings are P2 quality/edge-case concerns with no correctness impact under normal load.

No P0 or P1 issues found. Three P2 concerns: a benign race condition between throttle check and map update, a duplicate enum definition that could drift, and a pruning path that could evict still-fresh throttle entries under extreme scale. None affect correctness in normal deployments.

src/gateway/server-node-events.ts (throttle race + pruning logic), src/gateway/protocol/schema/nodes.ts (duplicate enum)

Comments Outside Diff (2)

  1. src/gateway/server-node-events.ts, line 1382-1408 (link)

    P2 Throttle check–set gap allows concurrent double-writes

    There is an await between the throttle read and the map update. Two concurrent handleNodeEvent calls for the same deviceId arriving within the same event-loop tick will both pass the now - lastPersistedAt < NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS check, both call Promise.all([updatePairedNodeMetadata, updatePairedDeviceMetadata]), and both write to the pairing store before either reaches recentNodePresencePersistAt.set(deviceId, now). The extra write is harmless in production (it just re-stamps the same timestamp), but it defeats the throttle's intent and means getRecentNodePresencePersistCountForTests() might not reflect the actual number of DB writes.

    A simple guard is to tentatively mark the device in the throttle map before awaiting, and remove it on failure:

    // Before the Promise.all
    recentNodePresencePersistAt.set(deviceId, now);   // optimistic lock
    // ... await ...
    // On failure path:
    recentNodePresencePersistAt.delete(deviceId);      // roll back if unpaired/error
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/gateway/server-node-events.ts
    Line: 1382-1408
    
    Comment:
    **Throttle check–set gap allows concurrent double-writes**
    
    There is an `await` between the throttle read and the map update. Two concurrent `handleNodeEvent` calls for the same `deviceId` arriving within the same event-loop tick will both pass the `now - lastPersistedAt < NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS` check, both call `Promise.all([updatePairedNodeMetadata, updatePairedDeviceMetadata])`, and both write to the pairing store before either reaches `recentNodePresencePersistAt.set(deviceId, now)`. The extra write is harmless in production (it just re-stamps the same timestamp), but it defeats the throttle's intent and means `getRecentNodePresencePersistCountForTests()` might not reflect the actual number of DB writes.
    
    A simple guard is to tentatively mark the device in the throttle map before awaiting, and remove it on failure:
    
    ```typescript
    // Before the Promise.all
    recentNodePresencePersistAt.set(deviceId, now);   // optimistic lock
    // ... await ...
    // On failure path:
    recentNodePresencePersistAt.delete(deviceId);      // roll back if unpaired/error
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/gateway/server-node-events.ts, line 1103-1126 (link)

    P2 Fallback eviction can remove in-window throttle entries

    When map.size > maxEntries and no entries are past the TTL, the for loop deletes nothing, and the while loop removes the oldest-inserted key unconditionally. Because the map is keyed by deviceId and values are updated in-place (not re-inserted), the "oldest-inserted" key is whatever device first appeared in the process's lifetime — its stored timestamp could still be within the 60-second throttle window. Removing it allows that device to bypass throttling immediately after eviction.

    With MAX_RECENT_NODE_PRESENCE_KEYS = 1024 this only matters at scale, but worth noting: the fallback could prefer evicting the entry with the largest elapsed time (now - ts) rather than the first insertion, to ensure the still-fresh entry is not mistakenly removed.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/gateway/server-node-events.ts
    Line: 1103-1126
    
    Comment:
    **Fallback eviction can remove in-window throttle entries**
    
    When `map.size > maxEntries` and no entries are past the TTL, the `for` loop deletes nothing, and the `while` loop removes the oldest-inserted key unconditionally. Because the map is keyed by `deviceId` and values are updated in-place (not re-inserted), the "oldest-inserted" key is whatever device first appeared in the process's lifetime — its stored timestamp could still be within the 60-second throttle window. Removing it allows that device to bypass throttling immediately after eviction.
    
    With `MAX_RECENT_NODE_PRESENCE_KEYS = 1024` this only matters at scale, but worth noting: the fallback could prefer evicting the entry with the *largest* elapsed time (`now - ts`) rather than the first insertion, to ensure the still-fresh entry is not mistakenly removed.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/server-node-events.ts
Line: 1382-1408

Comment:
**Throttle check–set gap allows concurrent double-writes**

There is an `await` between the throttle read and the map update. Two concurrent `handleNodeEvent` calls for the same `deviceId` arriving within the same event-loop tick will both pass the `now - lastPersistedAt < NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS` check, both call `Promise.all([updatePairedNodeMetadata, updatePairedDeviceMetadata])`, and both write to the pairing store before either reaches `recentNodePresencePersistAt.set(deviceId, now)`. The extra write is harmless in production (it just re-stamps the same timestamp), but it defeats the throttle's intent and means `getRecentNodePresencePersistCountForTests()` might not reflect the actual number of DB writes.

A simple guard is to tentatively mark the device in the throttle map before awaiting, and remove it on failure:

```typescript
// Before the Promise.all
recentNodePresencePersistAt.set(deviceId, now);   // optimistic lock
// ... await ...
// On failure path:
recentNodePresencePersistAt.delete(deviceId);      // roll back if unpaired/error
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/protocol/schema/nodes.ts
Line: 12-21

Comment:
**Presence-reason enum duplicated across two modules**

`NodePresenceAliveReasonSchema` here defines the closed set of six reasons inline, and `NODE_PRESENCE_ALIVE_REASONS` in `src/shared/node-presence.ts` defines the same list independently. Adding a new trigger (e.g. `"foreground_shortcut"`) to `node-presence.ts` but forgetting this schema (or vice-versa) will silently create a split: the runtime normalizer in `normalizeNodePresenceAliveReason` will accept it, but `validateNodePresenceAlivePayload` will reject it. Deriving one from the other (e.g. `enum: [...NODE_PRESENCE_ALIVE_REASONS]`) would keep them in sync.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/server-node-events.ts
Line: 1103-1126

Comment:
**Fallback eviction can remove in-window throttle entries**

When `map.size > maxEntries` and no entries are past the TTL, the `for` loop deletes nothing, and the `while` loop removes the oldest-inserted key unconditionally. Because the map is keyed by `deviceId` and values are updated in-place (not re-inserted), the "oldest-inserted" key is whatever device first appeared in the process's lifetime — its stored timestamp could still be within the 60-second throttle window. Removing it allows that device to bypass throttling immediately after eviction.

With `MAX_RECENT_NODE_PRESENCE_KEYS = 1024` this only matters at scale, but worth noting: the fallback could prefer evicting the entry with the *largest* elapsed time (`now - ts`) rather than the first insertion, to ensure the still-fresh entry is not mistakenly removed.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat: add iOS background presence beacon" | Re-trigger Greptile

Comment on lines +12 to +21
export const NodePresenceAliveReasonSchema = Type.String({
enum: [
"background",
"silent_push",
"bg_app_refresh",
"significant_location",
"manual",
"connect",
],
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Presence-reason enum duplicated across two modules

NodePresenceAliveReasonSchema here defines the closed set of six reasons inline, and NODE_PRESENCE_ALIVE_REASONS in src/shared/node-presence.ts defines the same list independently. Adding a new trigger (e.g. "foreground_shortcut") to node-presence.ts but forgetting this schema (or vice-versa) will silently create a split: the runtime normalizer in normalizeNodePresenceAliveReason will accept it, but validateNodePresenceAlivePayload will reject it. Deriving one from the other (e.g. enum: [...NODE_PRESENCE_ALIVE_REASONS]) would keep them in sync.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/protocol/schema/nodes.ts
Line: 12-21

Comment:
**Presence-reason enum duplicated across two modules**

`NodePresenceAliveReasonSchema` here defines the closed set of six reasons inline, and `NODE_PRESENCE_ALIVE_REASONS` in `src/shared/node-presence.ts` defines the same list independently. Adding a new trigger (e.g. `"foreground_shortcut"`) to `node-presence.ts` but forgetting this schema (or vice-versa) will silently create a split: the runtime normalizer in `normalizeNodePresenceAliveReason` will accept it, but `validateNodePresenceAlivePayload` will reject it. Deriving one from the other (e.g. `enum: [...NODE_PRESENCE_ALIVE_REASONS]`) would keep them in sync.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread apps/ios/Sources/Model/NodeAppModel.swift
steipete and others added 2 commits April 28, 2026 07:28
@steipete steipete force-pushed the fix/ios-background-presence-beacon branch from aeb3437 to 23ef816 Compare April 28, 2026 06:29
@openclaw-barnacle openclaw-barnacle Bot added app: macos App: macos size: XL and removed size: L labels Apr 28, 2026
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 28, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Potential DoS via oversized JSON payload in node presence alive event
1. 🟡 Potential DoS via oversized JSON payload in node presence alive event
Property Value
Severity Medium
CWE CWE-400
Location src/gateway/server-node-events.ts:801-805

Description

The NODE_PRESENCE_ALIVE_EVENT handler parses evt.payloadJSON with JSON.parse via parsePayloadObject() without any event-specific size bound.

  • The WebSocket connection max payload is set to MAX_PAYLOAD_BYTES = 25 * 1024 * 1024 after connect, so an authenticated client can send very large JSON frames.
  • parsePayloadObject() calls JSON.parse(payloadJSON) directly, which can cause significant CPU and memory pressure on large inputs.
  • The presence event is expected to be small (e.g., a trigger string), so allowing multi-megabyte payloads provides an unnecessary resource-exhaustion vector.

Vulnerable code:

case NODE_PRESENCE_ALIVE_EVENT: {
  const obj = parsePayloadObject(evt.payloadJSON);
  ...
}

function parsePayloadObject(payloadJSON?: string | null): Record<string, unknown> | null {
  ...
  payload = JSON.parse(payloadJSON) as unknown;
  ...
}

Recommendation

Add a strict maximum size for presence payloads before attempting to parse, and/or enforce it at schema validation.

Example (handler-level guard):

const MAX_PRESENCE_PAYLOAD_BYTES = 4 * 1024; // presence should be tiny

case NODE_PRESENCE_ALIVE_EVENT: {
  if (!evt.payloadJSON || Buffer.byteLength(evt.payloadJSON, "utf8") > MAX_PRESENCE_PAYLOAD_BYTES) {
    return { ok: true, event: evt.event, handled: false, reason: "payload_too_large" };
  }
  const obj = parsePayloadObject(evt.payloadJSON);
  ...
}

Optionally also constrain payloadJSON length in NodeEventParamsSchema for this event (or split into per-event schemas) to reject oversized input earlier.


Analyzed PR: #73330 at commit d5c1900

Last updated on: 2026-04-28T06:48:13Z

@openclaw-barnacle openclaw-barnacle Bot added the scripts Repository scripts label Apr 28, 2026
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d5c1900. Configure here.

updatePairedDeviceMetadataMock.mockResolvedValue(true);
updatePairedNodeMetadataMock.mockClear();
updatePairedNodeMetadataMock.mockResolvedValue(true);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate beforeEach block in same describe scope

Low Severity

A second beforeEach block at line 1018 duplicates a strict subset of the first beforeEach at line 186 within the same describe scope. In vitest, both hooks run before every test regardless of declaration order, so resetNodeEventDeduplicationForTests, updatePairedDeviceMetadataMock, and updatePairedNodeMetadataMock are cleared and re-initialized twice per test. The second block is entirely redundant.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d5c1900. Configure here.

@steipete
Copy link
Copy Markdown
Contributor Author

Addressed the background wake review finding.

  • apps/ios/Sources/Model/NodeAppModel.swift:3836 now checks the gateway connection before applying the recent-success throttle, so disconnected background wakes still take the reconnect lease/path.
  • apps/ios/Sources/Push/BackgroundAliveBeacon.swift:45 makes the throttle helper explicitly return false when the gateway is disconnected.
  • apps/ios/Tests/BackgroundAliveBeaconTests.swift:32 adds the regression for a recent successful beacon while disconnected.
  • Also fixed CI drift: regenerated Swift gateway protocol models and taught scripts/protocol-gen-swift.ts to emit top-level string enums, so NodePresenceAlivePayload.trigger compiles.

Verification:

  • xcodebuild test -project apps/ios/OpenClaw.xcodeproj -scheme OpenClaw -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:OpenClawTests/BackgroundAliveBeaconTests
  • xcodebuild build -project apps/ios/OpenClaw.xcodeproj -scheme OpenClaw -destination 'platform=iOS Simulator,name=iPhone 17'
  • pnpm protocol:check
  • pnpm lint:swift
  • swift build --package-path apps/macos --product OpenClaw --configuration release
  • local single-scenario QA: pnpm openclaw qa suite --provider-mode mock-openai --scenario thread-memory-isolation ... passed

Current exact-SHA CI: checks-fast-protocol and macos-swift are green. The parity gate is still red on thread-memory-isolation after one rerun, but the same scenario passes locally in mock mode and the failure is outside this iOS/protocol change path.

@steipete steipete merged commit bdba90a into main Apr 28, 2026
71 of 74 checks passed
@steipete steipete deleted the fix/ios-background-presence-beacon branch April 28, 2026 07:10
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
* 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>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: ios App: ios app: macos App: macos app: web-ui App: web-ui docs Improvements or additions to documentation gateway Gateway runtime maintainer Maintainer-authored PR scripts Repository scripts size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant