feat(ios): add background alive beacon support#63123
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟡 Unbounded presenceKey tracking enables memory/disk I/O DoS via `node.presence.alive` flooding
DescriptionThe new Impact:
Vulnerable code: const presenceKey = opts?.deviceId ?? nodeId;
const lastPersistedAt = recentNodePresencePersistAt.get(presenceKey) ?? 0;
...
await Promise.all([
updatePairedNodeMetadata(nodeId, { ... }),
...(opts?.deviceId ? [updatePairedDeviceMetadata(opts.deviceId, { ... })] : []),
]);
recentNodePresencePersistAt.set(presenceKey, receivedAtMs);RecommendationMitigate high-cardinality key abuse and avoid expensive work for unknown IDs:
Example (sketch): // 1) Maintain a bounded LRU/TTL cache
const recentNodePresencePersistAt = new LruCache<string, number>({ max: 10_000, ttl: 10 * 60_000 });
// 2) Verify paired node/device first (or provide a lightweight exists check)
const pairedNode = await getPairedNode(nodeId);
if (!pairedNode) return;
// 3) Throttle and persist
const key = opts?.deviceId ?? nodeId;
const last = recentNodePresencePersistAt.get(key) ?? 0;
if (Date.now() - last < NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS) return;
await updatePairedNodeMetadata(nodeId, patch);
recentNodePresencePersistAt.set(key, Date.now());2. 🟡 Unbounded node-controlled `trigger` persisted to pairing metadata (potential disk/memory DoS)
DescriptionThe gateway handles
A malicious/compromised node can send a very large
Vulnerable code: const trigger = normalizeOptionalString(obj.trigger) ?? "background";
...
updatePairedNodeMetadata(nodeId, { lastSeenReason: trigger })
...
updatePairedDeviceMetadata(opts.deviceId, { lastSeenReason: trigger })RecommendationConstrain
Example fix (allowlist + fallback): const rawTrigger = normalizeOptionalString(obj.trigger) ?? "background";
const allowed = new Set(["background","silent_push","bg_app_refresh","significant_location","connect"]);
const trigger = allowed.has(rawTrigger) ? rawTrigger : "background";If you must accept arbitrary strings, cap length: const trigger = (normalizeOptionalString(obj.trigger) ?? "background").slice(0, 64);This prevents oversized payloads from bloating persisted state and reduces downstream risk if the value is later displayed in UIs/logs. Analyzed PR: #63123 at commit Last updated on: 2026-04-08T11:31:15Z |
Greptile SummaryThis PR adds durable One minor inconsistency in the new Confidence Score: 5/5Safe to merge; the single finding is a minor write-key normalization inconsistency with no impact on current callers. All remaining findings are P2. The updatePairedDeviceMetadata write-key inconsistency is a real code issue but device IDs from authenticated sessions are crypto-derived and never contain whitespace, so it cannot manifest with existing callers. The rest of the change is well-structured with good test coverage of the gateway contract, catalog merge logic, and iOS throttle behavior. src/infra/device-pairing.ts — write key in updatePairedDeviceMetadata should use normalizeDeviceId(deviceId) for consistency.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c496da5a85
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Addressed the current review comments in What changed:
Added/updated coverage for:
Verification rerun:
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 89351872e0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") | ||
| return makeResult(true, "reconnect_triggered") | ||
| let nowMs = Int(Date().timeIntervalSince1970 * 1000) | ||
| let lastSuccessAtMs = UserDefaults.standard.object(forKey: Self.backgroundAliveBeaconLastSuccessAtMsKey) as? Int |
There was a problem hiding this comment.
Scope alive-beacon throttle key by gateway stable ID
This throttle check reads a single global UserDefaults timestamp, so a successful beacon for one gateway profile can suppress beacon delivery for another profile for 10 minutes when users switch gateways while still connected. In that case the wake is marked recent_success and node.event is skipped, so the newly active gateway never receives a fresh lastSeen update. Store/read this timestamp per active gateway identity (for example keyed by cfg.stableID or effective stable ID) rather than globally.
Useful? React with 👍 / 👎.
| if (receivedAtMs - lastPersistedAt < NODE_PRESENCE_PERSIST_MIN_INTERVAL_MS) { | ||
| return; |
There was a problem hiding this comment.
Handle backward clock jumps in presence throttle
This rate-limit check assumes wall-clock monotonicity; if system time moves backward (for example NTP correction), receivedAtMs - lastPersistedAt becomes negative and the branch will keep dropping node.presence.alive events until time catches up. That can suppress legitimate liveness persistence for an extended period. Add a monotonic guard (for example only throttle when receivedAtMs >= lastPersistedAt) or reset stale entries when the clock regresses.
Useful? React with 👍 / 👎.
steipete
left a comment
There was a problem hiding this comment.
Thanks. I reviewed current head 89351872e04ff36892e58c5f20daebea5c759558 against current main.
What this PR does: adds durable lastSeenAtMs / lastSeenReason for node/device pairing records, handles node.presence.alive through node.event, surfaces that in the node catalog, and changes the iOS silent-push/background-refresh/significant-location wake paths to reconnect briefly and publish an acked alive beacon. The underlying gap is real: current main only calls reconnectGatewaySessionsForSilentPushIfNeeded from the iOS wake handlers (apps/ios/Sources/Model/NodeAppModel.swift:3105, :3155, :3173) and gateway node.event has no durable presence/last-seen path (src/gateway/server-methods/nodes.ts:1128).
I’m requesting changes because the two persistence hardening issues are still not fully closed:
-
src/gateway/server-node-events.ts:690-718:triggeris still arbitrary and unbounded. The handler usesnormalizeOptionalString(obj.trigger) ?? "background"(:695), andnormalizeOptionalStringonly trims/coerces strings (src/shared/string-coerce.ts:5-15). That value is then persisted aslastSeenReasoninto node/device pairing JSON (src/infra/node-pairing.ts:316-349,src/infra/device-pairing.ts:737-766). A compromised or misconfigured node can still write a very large reason string into durable pairing state once per throttle interval, which means Aisle’striggerfinding is not really fixed. Best fix: strict allowlist the expected reasons (background,silent_push,bg_app_refresh,significant_location, and any intentionally supportedconnect-style value) or cap to a small fixed length before persistence, with a regression test for oversized/unknown triggers. -
src/gateway/server-node-events.ts:43-47and:690-718:recentNodePresencePersistAtis still an unbounded module-levelMap. The key isopts?.deviceId ?? nodeId(:697);node.eventonly passes the authenticated device id when present (src/gateway/server-methods/nodes.ts:1095-1142), otherwise the fallbacknodeIdremains the connect device/client id path (currentsrc/gateway/node-registry.ts:45-48). BothupdatePairedNodeMetadataandupdatePairedDeviceMetadatareturn without throwing for unknown ids (src/infra/node-pairing.ts:321-326,src/infra/device-pairing.ts:744-749), so the handler can still set throttle entries for unpaired/high-cardinality ids after the disk-backed lookup resolves. Best fix: bound/TTL/LRU this cache and avoid recording throttle entries unless an approved node/device actually existed or was updated. Please add coverage that unknown/high-cardinality presence ids do not grow the cache or drive unbounded pairing-file work.
The red checks-node-test job is a broad extension-shard heap OOM, so I’m not treating that alone as evidence this PR broke tests. After the fixes and rebase, though, this needs fresh targeted gateway/infra tests plus the relevant changed gate.
|
Thanks again for opening this. I rebuilt the feature as #73330 so we keep the useful iOS background alive signal while tightening the protocol boundary:
I kept attribution in the changelog and commit co-author trailer. |
|
Thanks for the context here. I swept through the related work, and this is now duplicate or superseded. Keep open only because this is a member-authored, maintainer-labeled PR; technically, the branch is superseded by the merged replacement at #73330 and remains a worse landing path because it still carries security/availability issues. Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Canonical path: Close this PR as superseded by #73330. So I’m closing this here and keeping the remaining discussion on #73330. Review detailsBest possible solution: Close this PR as superseded by #73330. Do we have a high-confidence way to reproduce the issue? Yes for the review findings: the PR diff shows the freeform trigger persistence and unbounded throttle map directly. I did not run a live iOS/gateway scenario in this read-only cleanup review. Is this the best way to solve the issue? No, this branch is no longer the best way to solve the issue. The merged replacement is narrower and safer because it uses authenticated device identity, closed reason normalization, bounded throttling, docs, and regression tests. Security review: Security review needs attention: The stale branch still has concrete resource-exhaustion concerns in the node-authenticated presence persistence path; the merged replacement addresses the same class of issues.
AGENTS.md: found and applied where relevant. What I checked:
Likely related people:
Codex review notes: model internal, reasoning high; reviewed against 010b61746379. |
|
Thanks again for the original background-presence work. This is superseded by #73330, merged as bdba90a and shipped in v2026.4.27. The replacement preserves the useful iOS wake beacon while requiring authenticated device identity, normalizing reasons to a closed set, returning meaningful handling results, and bounding persistence throttling. Contributor credit was preserved there, so this older branch is no longer the landing path. |
Summary
lastSeenAtMs/lastSeenReasonmetadata, anode.presence.aliveevent path, and iOS background wake logic that reconnects briefly if needed and sends an acked alive beacon.connected/connectedAtMsstill mean live websocket state now; this does not try to keep a persistent background socket alive.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause (if applicable)
Regression Test Plan (if applicable)
src/gateway/server-node-events.test.tssrc/gateway/node-catalog.test.tssrc/infra/node-pairing.test.tsapps/ios/Tests/NodeBackgroundAliveBeaconTests.swiftapps/ios/Tests/NodeBackgroundAliveE2ETests.swiftUser-visible / Behavior Changes
lastSeenAtMs/lastSeenReasoneven when not currently connected.Diagram (if applicable)
Security Impact (required)
Yes, explain risk + mitigation: adds a node-authenticatednode.eventusage fornode.presence.alive; it does not require operator auth and keepsconnectedsemantics unchanged.Repro + Verification
Environment
.local/ios-background-alive-e2e/Steps
node.listplus targeted tests.Expected
connectedsemantics.lastSeenAtMs/lastSeenReasonfor recent-alive updates.Actual
Evidence
Attach at least one:
Human Verification (required)
xcodebuild test -scheme OpenClawLogicTests ...passesnode.listinspection matches expected last-seen fieldsOpenClawTestsexecution in Simulator (build-for-testing succeeded, but the test runner hung before connecting)Review Conversations
Compatibility / Migration
Risks and Mitigations