diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4652289337a..7afb8cc61be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -72,6 +72,7 @@ Docs: https://docs.openclaw.ai
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
- LLM Task/Lobster: add an optional `thinking` override so workflow calls can explicitly set embedded reasoning level with shared validation for invalid values and unsupported `xhigh` modes. (#15606) Thanks @xadenryan and @ImLukeF.
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
+- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman.
### Breaking
diff --git a/apps/ios/README.md b/apps/ios/README.md
index 6eb35a1d639..7a2af328ee7 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -62,12 +62,18 @@ Release behavior:
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
+- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.11-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.11`
- `CFBundleVersion = next TestFlight build number for 2026.3.11`
+Required env for beta builds:
+
+- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
+ This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
+
Archive without upload:
```bash
@@ -91,9 +97,43 @@ pnpm ios:beta -- --build-number 7
- The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
+- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
-- Debug builds register as APNs sandbox; Release builds use production.
+- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
+
+## APNs Expectations For Official Builds
+
+- Official/TestFlight builds register with the external push relay before they publish `push.apns.register` to the gateway.
+- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, relay origin metadata, and installation metadata instead of the raw APNs token.
+- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
+- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
+- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
+- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration.
+- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
+
+## Official Build Relay Trust Model
+
+- `iOS -> gateway`
+ - The app must pair with the gateway and establish both node and operator sessions.
+ - The operator session is used to fetch `gateway.identity.get`.
+- `iOS -> relay`
+ - The app registers with the relay over HTTPS using App Attest plus the app receipt.
+ - The relay requires the official production/TestFlight distribution path, which is why local
+ Xcode/dev installs cannot use the hosted relay.
+- `gateway delegation`
+ - The app includes the gateway identity in relay registration.
+ - The relay returns a relay handle and registration-scoped send grant delegated to that gateway.
+- `gateway -> relay`
+ - The gateway signs relay send requests with its own device identity.
+ - The relay verifies both the delegated send grant and the gateway signature before it sends to
+ APNs.
+- `relay -> APNs`
+ - Production APNs credentials and raw official-build APNs tokens stay in the relay deployment,
+ not on the gateway.
+
+This exists to keep the hosted relay limited to genuine OpenClaw official builds and to ensure a
+gateway can only send pushes for iOS devices that paired with that gateway.
## What Works Now (Concrete)
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 892d53e7ae9..5908021fad3 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -66,6 +66,14 @@
OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities
+ OpenClawPushAPNsEnvironment
+ $(OPENCLAW_PUSH_APNS_ENVIRONMENT)
+ OpenClawPushDistribution
+ $(OPENCLAW_PUSH_DISTRIBUTION)
+ OpenClawPushRelayBaseURL
+ $(OPENCLAW_PUSH_RELAY_BASE_URL)
+ OpenClawPushTransport
+ $(OPENCLAW_PUSH_TRANSPORT)
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index 685b30f0887..ad21503f5d2 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -12,6 +12,12 @@ import UserNotifications
private struct NotificationCallError: Error, Sendable {
let message: String
}
+
+private struct GatewayRelayIdentityResponse: Decodable {
+ let deviceId: String
+ let publicKey: String
+}
+
// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch: @unchecked Sendable {
private let lock = NSLock()
@@ -140,6 +146,7 @@ final class NodeAppModel {
private var shareDeliveryTo: String?
private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String?
+ @ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var gatewaySession: GatewayNodeSession { self.nodeGateway }
var operatorSession: GatewayNodeSession { self.operatorGateway }
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
@@ -528,13 +535,6 @@ final class NodeAppModel {
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
- private static var apnsEnvironment: String {
-#if DEBUG
- "sandbox"
-#else
- "production"
-#endif
- }
private func refreshBrandingFromGateway() async {
do {
@@ -1189,7 +1189,15 @@ final class NodeAppModel {
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
}
- return await self.notificationAuthorizationStatus()
+ let updatedStatus = await self.notificationAuthorizationStatus()
+ if Self.isNotificationAuthorizationAllowed(updatedStatus) {
+ // Refresh APNs registration immediately after the first permission grant so the
+ // gateway can receive a push registration without requiring an app relaunch.
+ await MainActor.run {
+ UIApplication.shared.registerForRemoteNotifications()
+ }
+ }
+ return updatedStatus
}
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
@@ -1204,6 +1212,17 @@ final class NodeAppModel {
}
}
+ private static func isNotificationAuthorizationAllowed(
+ _ status: NotificationAuthorizationStatus
+ ) -> Bool {
+ switch status {
+ case .authorized, .provisional, .ephemeral:
+ true
+ case .denied, .notDetermined:
+ false
+ }
+ }
+
private func runNotificationCall(
timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T
@@ -1834,6 +1853,7 @@ private extension NodeAppModel {
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
await self.refreshShareRouteFromGateway()
+ await self.registerAPNsTokenIfNeeded()
await self.startVoiceWakeSync()
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
await MainActor.run { self.startGatewayHealthMonitor() }
@@ -2479,7 +2499,8 @@ extension NodeAppModel {
else {
return
}
- if token == self.apnsLastRegisteredTokenHex {
+ let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
+ if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
return
}
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
@@ -2488,25 +2509,40 @@ extension NodeAppModel {
return
}
- struct PushRegistrationPayload: Codable {
- var token: String
- var topic: String
- var environment: String
- }
-
- let payload = PushRegistrationPayload(
- token: token,
- topic: topic,
- environment: Self.apnsEnvironment)
do {
- let json = try Self.encodePayload(payload)
- await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json)
+ let gatewayIdentity: PushRelayGatewayIdentity?
+ if usesRelayTransport {
+ guard self.operatorConnected else { return }
+ gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
+ } else {
+ gatewayIdentity = nil
+ }
+ let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload(
+ apnsTokenHex: token,
+ topic: topic,
+ gatewayIdentity: gatewayIdentity)
+ await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON)
self.apnsLastRegisteredTokenHex = token
} catch {
- // Best-effort only.
+ self.pushWakeLogger.error(
+ "APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
}
}
+ private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
+ let response = try await self.operatorGateway.request(
+ method: "gateway.identity.get",
+ paramsJSON: "{}",
+ timeoutSeconds: 8)
+ let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
+ let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
+ let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !deviceId.isEmpty, !publicKey.isEmpty else {
+ throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields")
+ }
+ return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey)
+ }
+
private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
guard let apsAny = userInfo["aps"] else { return false }
if let aps = apsAny as? [AnyHashable: Any] {
diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift
index c94b1209f8d..ae980b0216a 100644
--- a/apps/ios/Sources/OpenClawApp.swift
+++ b/apps/ios/Sources/OpenClawApp.swift
@@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge {
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if !granted { return false }
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
+ if self.isAuthorizationStatusAllowed(updatedStatus) {
+ // Refresh APNs registration immediately after the first permission grant so the
+ // gateway can receive a push registration without requiring an app relaunch.
+ await MainActor.run {
+ UIApplication.shared.registerForRemoteNotifications()
+ }
+ }
return self.isAuthorizationStatusAllowed(updatedStatus)
case .denied:
return false
diff --git a/apps/ios/Sources/Push/PushBuildConfig.swift b/apps/ios/Sources/Push/PushBuildConfig.swift
new file mode 100644
index 00000000000..d1665921552
--- /dev/null
+++ b/apps/ios/Sources/Push/PushBuildConfig.swift
@@ -0,0 +1,75 @@
+import Foundation
+
+enum PushTransportMode: String {
+ case direct
+ case relay
+}
+
+enum PushDistributionMode: String {
+ case local
+ case official
+}
+
+enum PushAPNsEnvironment: String {
+ case sandbox
+ case production
+}
+
+struct PushBuildConfig {
+ let transport: PushTransportMode
+ let distribution: PushDistributionMode
+ let relayBaseURL: URL?
+ let apnsEnvironment: PushAPNsEnvironment
+
+ static let current = PushBuildConfig()
+
+ init(bundle: Bundle = .main) {
+ self.transport = Self.readEnum(
+ bundle: bundle,
+ key: "OpenClawPushTransport",
+ fallback: .direct)
+ self.distribution = Self.readEnum(
+ bundle: bundle,
+ key: "OpenClawPushDistribution",
+ fallback: .local)
+ self.apnsEnvironment = Self.readEnum(
+ bundle: bundle,
+ key: "OpenClawPushAPNsEnvironment",
+ fallback: Self.defaultAPNsEnvironment)
+ self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
+ }
+
+ var usesRelay: Bool {
+ self.transport == .relay
+ }
+
+ private static func readURL(bundle: Bundle, key: String) -> URL? {
+ guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ guard let components = URLComponents(string: trimmed),
+ components.scheme?.lowercased() == "https",
+ let host = components.host,
+ !host.isEmpty,
+ components.user == nil,
+ components.password == nil,
+ components.query == nil,
+ components.fragment == nil
+ else {
+ return nil
+ }
+ return components.url
+ }
+
+ private static func readEnum(
+ bundle: Bundle,
+ key: String,
+ fallback: T)
+ -> T where T.RawValue == String {
+ guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return T(rawValue: trimmed) ?? fallback
+ }
+
+ private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
+}
diff --git a/apps/ios/Sources/Push/PushRegistrationManager.swift b/apps/ios/Sources/Push/PushRegistrationManager.swift
new file mode 100644
index 00000000000..77f54f8d108
--- /dev/null
+++ b/apps/ios/Sources/Push/PushRegistrationManager.swift
@@ -0,0 +1,169 @@
+import CryptoKit
+import Foundation
+
+private struct DirectGatewayPushRegistrationPayload: Encodable {
+ var transport: String = PushTransportMode.direct.rawValue
+ var token: String
+ var topic: String
+ var environment: String
+}
+
+private struct RelayGatewayPushRegistrationPayload: Encodable {
+ var transport: String = PushTransportMode.relay.rawValue
+ var relayHandle: String
+ var sendGrant: String
+ var gatewayDeviceId: String
+ var installationId: String
+ var topic: String
+ var environment: String
+ var distribution: String
+ var tokenDebugSuffix: String?
+}
+
+struct PushRelayGatewayIdentity: Codable {
+ var deviceId: String
+ var publicKey: String
+}
+
+actor PushRegistrationManager {
+ private let buildConfig: PushBuildConfig
+ private let relayClient: PushRelayClient?
+
+ var usesRelayTransport: Bool {
+ self.buildConfig.transport == .relay
+ }
+
+ init(buildConfig: PushBuildConfig = .current) {
+ self.buildConfig = buildConfig
+ self.relayClient = buildConfig.relayBaseURL.map { PushRelayClient(baseURL: $0) }
+ }
+
+ func makeGatewayRegistrationPayload(
+ apnsTokenHex: String,
+ topic: String,
+ gatewayIdentity: PushRelayGatewayIdentity?)
+ async throws -> String {
+ switch self.buildConfig.transport {
+ case .direct:
+ return try Self.encodePayload(
+ DirectGatewayPushRegistrationPayload(
+ token: apnsTokenHex,
+ topic: topic,
+ environment: self.buildConfig.apnsEnvironment.rawValue))
+ case .relay:
+ guard let gatewayIdentity else {
+ throw PushRelayError.relayMisconfigured("Missing gateway identity for relay registration")
+ }
+ return try await self.makeRelayPayload(
+ apnsTokenHex: apnsTokenHex,
+ topic: topic,
+ gatewayIdentity: gatewayIdentity)
+ }
+ }
+
+ private func makeRelayPayload(
+ apnsTokenHex: String,
+ topic: String,
+ gatewayIdentity: PushRelayGatewayIdentity)
+ async throws -> String {
+ guard self.buildConfig.distribution == .official else {
+ throw PushRelayError.relayMisconfigured(
+ "Relay transport requires OpenClawPushDistribution=official")
+ }
+ guard self.buildConfig.apnsEnvironment == .production else {
+ throw PushRelayError.relayMisconfigured(
+ "Relay transport requires OpenClawPushAPNsEnvironment=production")
+ }
+ guard let relayClient = self.relayClient else {
+ throw PushRelayError.relayBaseURLMissing
+ }
+ guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !bundleId.isEmpty
+ else {
+ throw PushRelayError.relayMisconfigured("Missing bundle identifier for relay registration")
+ }
+ guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !installationId.isEmpty
+ else {
+ throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
+ }
+
+ let tokenHashHex = Self.sha256Hex(apnsTokenHex)
+ let relayOrigin = relayClient.normalizedBaseURLString
+ if let stored = PushRelayRegistrationStore.loadRegistrationState(),
+ stored.installationId == installationId,
+ stored.gatewayDeviceId == gatewayIdentity.deviceId,
+ stored.relayOrigin == relayOrigin,
+ stored.lastAPNsTokenHashHex == tokenHashHex,
+ !Self.isExpired(stored.relayHandleExpiresAtMs)
+ {
+ return try Self.encodePayload(
+ RelayGatewayPushRegistrationPayload(
+ relayHandle: stored.relayHandle,
+ sendGrant: stored.sendGrant,
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ installationId: installationId,
+ topic: topic,
+ environment: self.buildConfig.apnsEnvironment.rawValue,
+ distribution: self.buildConfig.distribution.rawValue,
+ tokenDebugSuffix: stored.tokenDebugSuffix))
+ }
+
+ let response = try await relayClient.register(
+ installationId: installationId,
+ bundleId: bundleId,
+ appVersion: DeviceInfoHelper.appVersion(),
+ environment: self.buildConfig.apnsEnvironment,
+ distribution: self.buildConfig.distribution,
+ apnsTokenHex: apnsTokenHex,
+ gatewayIdentity: gatewayIdentity)
+ let registrationState = PushRelayRegistrationStore.RegistrationState(
+ relayHandle: response.relayHandle,
+ sendGrant: response.sendGrant,
+ relayOrigin: relayOrigin,
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ relayHandleExpiresAtMs: response.expiresAtMs,
+ tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
+ lastAPNsTokenHashHex: tokenHashHex,
+ installationId: installationId,
+ lastTransport: self.buildConfig.transport.rawValue)
+ _ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
+ return try Self.encodePayload(
+ RelayGatewayPushRegistrationPayload(
+ relayHandle: response.relayHandle,
+ sendGrant: response.sendGrant,
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ installationId: installationId,
+ topic: topic,
+ environment: self.buildConfig.apnsEnvironment.rawValue,
+ distribution: self.buildConfig.distribution.rawValue,
+ tokenDebugSuffix: registrationState.tokenDebugSuffix))
+ }
+
+ private static func isExpired(_ expiresAtMs: Int64?) -> Bool {
+ guard let expiresAtMs else { return true }
+ let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
+ // Refresh shortly before expiry so reconnect-path republishes a live handle.
+ return expiresAtMs <= nowMs + 60_000
+ }
+
+ private static func sha256Hex(_ value: String) -> String {
+ let digest = SHA256.hash(data: Data(value.utf8))
+ return digest.map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func normalizeTokenSuffix(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func encodePayload(_ payload: some Encodable) throws -> String {
+ let data = try JSONEncoder().encode(payload)
+ guard let json = String(data: data, encoding: .utf8) else {
+ throw PushRelayError.relayMisconfigured("Failed to encode push registration payload as UTF-8")
+ }
+ return json
+ }
+}
diff --git a/apps/ios/Sources/Push/PushRelayClient.swift b/apps/ios/Sources/Push/PushRelayClient.swift
new file mode 100644
index 00000000000..07bb5caa3b7
--- /dev/null
+++ b/apps/ios/Sources/Push/PushRelayClient.swift
@@ -0,0 +1,349 @@
+import CryptoKit
+import DeviceCheck
+import Foundation
+import StoreKit
+
+enum PushRelayError: LocalizedError {
+ case relayBaseURLMissing
+ case relayMisconfigured(String)
+ case invalidResponse(String)
+ case requestFailed(status: Int, message: String)
+ case unsupportedAppAttest
+ case missingReceipt
+
+ var errorDescription: String? {
+ switch self {
+ case .relayBaseURLMissing:
+ "Push relay base URL missing"
+ case let .relayMisconfigured(message):
+ message
+ case let .invalidResponse(message):
+ message
+ case let .requestFailed(status, message):
+ "Push relay request failed (\(status)): \(message)"
+ case .unsupportedAppAttest:
+ "App Attest unavailable on this device"
+ case .missingReceipt:
+ "App Store receipt missing after refresh"
+ }
+ }
+}
+
+private struct PushRelayChallengeResponse: Decodable {
+ var challengeId: String
+ var challenge: String
+ var expiresAtMs: Int64
+}
+
+private struct PushRelayRegisterSignedPayload: Encodable {
+ var challengeId: String
+ var installationId: String
+ var bundleId: String
+ var environment: String
+ var distribution: String
+ var gateway: PushRelayGatewayIdentity
+ var appVersion: String
+ var apnsToken: String
+}
+
+private struct PushRelayAppAttestPayload: Encodable {
+ var keyId: String
+ var attestationObject: String?
+ var assertion: String
+ var clientDataHash: String
+ var signedPayloadBase64: String
+}
+
+private struct PushRelayReceiptPayload: Encodable {
+ var base64: String
+}
+
+private struct PushRelayRegisterRequest: Encodable {
+ var challengeId: String
+ var installationId: String
+ var bundleId: String
+ var environment: String
+ var distribution: String
+ var gateway: PushRelayGatewayIdentity
+ var appVersion: String
+ var apnsToken: String
+ var appAttest: PushRelayAppAttestPayload
+ var receipt: PushRelayReceiptPayload
+}
+
+struct PushRelayRegisterResponse: Decodable {
+ var relayHandle: String
+ var sendGrant: String
+ var expiresAtMs: Int64?
+ var tokenSuffix: String?
+ var status: String
+}
+
+private struct RelayErrorResponse: Decodable {
+ var error: String?
+ var message: String?
+ var reason: String?
+}
+
+private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate {
+ private var continuation: CheckedContinuation?
+ private var activeRequest: SKReceiptRefreshRequest?
+
+ func refresh() async throws {
+ try await withCheckedThrowingContinuation { continuation in
+ self.continuation = continuation
+ let request = SKReceiptRefreshRequest()
+ self.activeRequest = request
+ request.delegate = self
+ request.start()
+ }
+ }
+
+ func requestDidFinish(_ request: SKRequest) {
+ self.continuation?.resume(returning: ())
+ self.continuation = nil
+ self.activeRequest = nil
+ }
+
+ func request(_ request: SKRequest, didFailWithError error: Error) {
+ self.continuation?.resume(throwing: error)
+ self.continuation = nil
+ self.activeRequest = nil
+ }
+}
+
+private struct PushRelayAppAttestProof {
+ var keyId: String
+ var attestationObject: String?
+ var assertion: String
+ var clientDataHash: String
+ var signedPayloadBase64: String
+}
+
+private final class PushRelayAppAttestService {
+ func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
+ let service = DCAppAttestService.shared
+ guard service.isSupported else {
+ throw PushRelayError.unsupportedAppAttest
+ }
+
+ let keyID = try await self.loadOrCreateKeyID(using: service)
+ let attestationObject = try await self.attestKeyIfNeeded(
+ service: service,
+ keyID: keyID,
+ challenge: challenge)
+ let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
+ let assertion = try await self.generateAssertion(
+ service: service,
+ keyID: keyID,
+ signedPayloadHash: signedPayloadHash)
+
+ return PushRelayAppAttestProof(
+ keyId: keyID,
+ attestationObject: attestationObject,
+ assertion: assertion.base64EncodedString(),
+ clientDataHash: Self.base64URL(signedPayloadHash),
+ signedPayloadBase64: signedPayload.base64EncodedString())
+ }
+
+ private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
+ if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
+ return existing
+ }
+ let keyID = try await service.generateKey()
+ _ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
+ return keyID
+ }
+
+ private func attestKeyIfNeeded(
+ service: DCAppAttestService,
+ keyID: String,
+ challenge: String)
+ async throws -> String? {
+ if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
+ return nil
+ }
+ let challengeData = Data(challenge.utf8)
+ let clientDataHash = Data(SHA256.hash(data: challengeData))
+ let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash)
+ // Apple treats App Attest key attestation as a one-time operation. Save the
+ // attested marker immediately so later receipt/network failures do not cause a
+ // permanently broken re-attestation loop on the same key.
+ _ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
+ return attestation.base64EncodedString()
+ }
+
+ private func generateAssertion(
+ service: DCAppAttestService,
+ keyID: String,
+ signedPayloadHash: Data)
+ async throws -> Data {
+ do {
+ return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
+ } catch {
+ _ = PushRelayRegistrationStore.clearAppAttestKeyID()
+ _ = PushRelayRegistrationStore.clearAttestedKeyID()
+ throw error
+ }
+ }
+
+ private static func base64URL(_ data: Data) -> String {
+ data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+}
+
+private final class PushRelayReceiptProvider {
+ func loadReceiptBase64() async throws -> String {
+ if let receipt = self.readReceiptData() {
+ return receipt.base64EncodedString()
+ }
+ let refreshCoordinator = PushRelayReceiptRefreshCoordinator()
+ try await refreshCoordinator.refresh()
+ if let refreshed = self.readReceiptData() {
+ return refreshed.base64EncodedString()
+ }
+ throw PushRelayError.missingReceipt
+ }
+
+ private func readReceiptData() -> Data? {
+ guard let url = Bundle.main.appStoreReceiptURL else { return nil }
+ guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil }
+ return data
+ }
+}
+
+// The client is constructed once and used behind PushRegistrationManager actor isolation.
+final class PushRelayClient: @unchecked Sendable {
+ private let baseURL: URL
+ private let session: URLSession
+ private let jsonDecoder = JSONDecoder()
+ private let jsonEncoder = JSONEncoder()
+ private let appAttest = PushRelayAppAttestService()
+ private let receiptProvider = PushRelayReceiptProvider()
+
+ init(baseURL: URL, session: URLSession = .shared) {
+ self.baseURL = baseURL
+ self.session = session
+ }
+
+ var normalizedBaseURLString: String {
+ Self.normalizeBaseURLString(self.baseURL)
+ }
+
+ func register(
+ installationId: String,
+ bundleId: String,
+ appVersion: String,
+ environment: PushAPNsEnvironment,
+ distribution: PushDistributionMode,
+ apnsTokenHex: String,
+ gatewayIdentity: PushRelayGatewayIdentity)
+ async throws -> PushRelayRegisterResponse {
+ let challenge = try await self.fetchChallenge()
+ let signedPayload = PushRelayRegisterSignedPayload(
+ challengeId: challenge.challengeId,
+ installationId: installationId,
+ bundleId: bundleId,
+ environment: environment.rawValue,
+ distribution: distribution.rawValue,
+ gateway: gatewayIdentity,
+ appVersion: appVersion,
+ apnsToken: apnsTokenHex)
+ let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
+ let appAttest = try await self.appAttest.createProof(
+ challenge: challenge.challenge,
+ signedPayload: signedPayloadData)
+ let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
+ let requestBody = PushRelayRegisterRequest(
+ challengeId: signedPayload.challengeId,
+ installationId: signedPayload.installationId,
+ bundleId: signedPayload.bundleId,
+ environment: signedPayload.environment,
+ distribution: signedPayload.distribution,
+ gateway: signedPayload.gateway,
+ appVersion: signedPayload.appVersion,
+ apnsToken: signedPayload.apnsToken,
+ appAttest: PushRelayAppAttestPayload(
+ keyId: appAttest.keyId,
+ attestationObject: appAttest.attestationObject,
+ assertion: appAttest.assertion,
+ clientDataHash: appAttest.clientDataHash,
+ signedPayloadBase64: appAttest.signedPayloadBase64),
+ receipt: PushRelayReceiptPayload(base64: receiptBase64))
+
+ let endpoint = self.baseURL.appending(path: "v1/push/register")
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 20
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try self.jsonEncoder.encode(requestBody)
+
+ let (data, response) = try await self.session.data(for: request)
+ let status = Self.statusCode(from: response)
+ guard (200..<300).contains(status) else {
+ if status == 401 {
+ // If the relay rejects registration, drop local App Attest state so the next
+ // attempt re-attests instead of getting stuck without an attestation object.
+ _ = PushRelayRegistrationStore.clearAppAttestKeyID()
+ _ = PushRelayRegistrationStore.clearAttestedKeyID()
+ }
+ throw PushRelayError.requestFailed(
+ status: status,
+ message: Self.decodeErrorMessage(data: data))
+ }
+ let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
+ return decoded
+ }
+
+ private func fetchChallenge() async throws -> PushRelayChallengeResponse {
+ let endpoint = self.baseURL.appending(path: "v1/push/challenge")
+ var request = URLRequest(url: endpoint)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 10
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = Data("{}".utf8)
+
+ let (data, response) = try await self.session.data(for: request)
+ let status = Self.statusCode(from: response)
+ guard (200..<300).contains(status) else {
+ throw PushRelayError.requestFailed(
+ status: status,
+ message: Self.decodeErrorMessage(data: data))
+ }
+ return try self.decode(PushRelayChallengeResponse.self, from: data)
+ }
+
+ private func decode(_ type: T.Type, from data: Data) throws -> T {
+ do {
+ return try self.jsonDecoder.decode(type, from: data)
+ } catch {
+ throw PushRelayError.invalidResponse(error.localizedDescription)
+ }
+ }
+
+ private static func statusCode(from response: URLResponse) -> Int {
+ (response as? HTTPURLResponse)?.statusCode ?? 0
+ }
+
+ private static func normalizeBaseURLString(_ url: URL) -> String {
+ var absolute = url.absoluteString
+ while absolute.hasSuffix("/") {
+ absolute.removeLast()
+ }
+ return absolute
+ }
+
+ private static func decodeErrorMessage(data: Data) -> String {
+ if let decoded = try? JSONDecoder().decode(RelayErrorResponse.self, from: data) {
+ let message = decoded.message ?? decoded.reason ?? decoded.error ?? ""
+ if !message.isEmpty {
+ return message
+ }
+ }
+ let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return raw.isEmpty ? "unknown relay error" : raw
+ }
+}
diff --git a/apps/ios/Sources/Push/PushRelayKeychainStore.swift b/apps/ios/Sources/Push/PushRelayKeychainStore.swift
new file mode 100644
index 00000000000..4d7df09cd14
--- /dev/null
+++ b/apps/ios/Sources/Push/PushRelayKeychainStore.swift
@@ -0,0 +1,112 @@
+import Foundation
+
+private struct StoredPushRelayRegistrationState: Codable {
+ var relayHandle: String
+ var sendGrant: String
+ var relayOrigin: String?
+ var gatewayDeviceId: String
+ var relayHandleExpiresAtMs: Int64?
+ var tokenDebugSuffix: String?
+ var lastAPNsTokenHashHex: String
+ var installationId: String
+ var lastTransport: String
+}
+
+enum PushRelayRegistrationStore {
+ private static let service = "ai.openclaw.pushrelay"
+ private static let registrationStateAccount = "registration-state"
+ private static let appAttestKeyIDAccount = "app-attest-key-id"
+ private static let appAttestedKeyIDAccount = "app-attested-key-id"
+
+ struct RegistrationState: Codable {
+ var relayHandle: String
+ var sendGrant: String
+ var relayOrigin: String?
+ var gatewayDeviceId: String
+ var relayHandleExpiresAtMs: Int64?
+ var tokenDebugSuffix: String?
+ var lastAPNsTokenHashHex: String
+ var installationId: String
+ var lastTransport: String
+ }
+
+ static func loadRegistrationState() -> RegistrationState? {
+ guard let raw = KeychainStore.loadString(
+ service: self.service,
+ account: self.registrationStateAccount),
+ let data = raw.data(using: .utf8),
+ let decoded = try? JSONDecoder().decode(StoredPushRelayRegistrationState.self, from: data)
+ else {
+ return nil
+ }
+ return RegistrationState(
+ relayHandle: decoded.relayHandle,
+ sendGrant: decoded.sendGrant,
+ relayOrigin: decoded.relayOrigin,
+ gatewayDeviceId: decoded.gatewayDeviceId,
+ relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs,
+ tokenDebugSuffix: decoded.tokenDebugSuffix,
+ lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
+ installationId: decoded.installationId,
+ lastTransport: decoded.lastTransport)
+ }
+
+ @discardableResult
+ static func saveRegistrationState(_ state: RegistrationState) -> Bool {
+ let stored = StoredPushRelayRegistrationState(
+ relayHandle: state.relayHandle,
+ sendGrant: state.sendGrant,
+ relayOrigin: state.relayOrigin,
+ gatewayDeviceId: state.gatewayDeviceId,
+ relayHandleExpiresAtMs: state.relayHandleExpiresAtMs,
+ tokenDebugSuffix: state.tokenDebugSuffix,
+ lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
+ installationId: state.installationId,
+ lastTransport: state.lastTransport)
+ guard let data = try? JSONEncoder().encode(stored),
+ let raw = String(data: data, encoding: .utf8)
+ else {
+ return false
+ }
+ return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
+ }
+
+ @discardableResult
+ static func clearRegistrationState() -> Bool {
+ KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
+ }
+
+ static func loadAppAttestKeyID() -> String? {
+ let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if value?.isEmpty == false { return value }
+ return nil
+ }
+
+ @discardableResult
+ static func saveAppAttestKeyID(_ keyID: String) -> Bool {
+ KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
+ }
+
+ @discardableResult
+ static func clearAppAttestKeyID() -> Bool {
+ KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
+ }
+
+ static func loadAttestedKeyID() -> String? {
+ let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ if value?.isEmpty == false { return value }
+ return nil
+ }
+
+ @discardableResult
+ static func saveAttestedKeyID(_ keyID: String) -> Bool {
+ KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
+ }
+
+ @discardableResult
+ static func clearAttestedKeyID() -> Bool {
+ KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
+ }
+}
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 91b2a8e46d1..53e6489a25b 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -98,6 +98,17 @@ targets:
SUPPORTS_LIVE_ACTIVITIES: YES
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
+ configs:
+ Debug:
+ OPENCLAW_PUSH_TRANSPORT: direct
+ OPENCLAW_PUSH_DISTRIBUTION: local
+ OPENCLAW_PUSH_RELAY_BASE_URL: ""
+ OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
+ Release:
+ OPENCLAW_PUSH_TRANSPORT: direct
+ OPENCLAW_PUSH_DISTRIBUTION: local
+ OPENCLAW_PUSH_RELAY_BASE_URL: ""
+ OPENCLAW_PUSH_APNS_ENVIRONMENT: production
info:
path: Sources/Info.plist
properties:
@@ -131,6 +142,10 @@ targets:
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false
+ OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
+ OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
+ OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
+ OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
index 4848043980b..8a2f4e4bbdc 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
@@ -892,7 +892,8 @@ public actor GatewayChannelActor {
return (id: id, data: data)
} catch {
self.logger.error(
- "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
+ "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
+ )
throw error
}
}
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index db5077aebcf..b26cefeb11c 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -2447,6 +2447,14 @@ See [Plugins](/tools/plugin).
// Remove tools from the default HTTP deny list
allow: ["gateway"],
},
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 10000,
+ },
+ },
+ },
},
}
```
@@ -2472,6 +2480,11 @@ See [Plugins](/tools/plugin).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
+- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build.
+- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`.
+- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
+- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
+- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index ece612d101d..d7e5f5c25d3 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -225,6 +225,63 @@ When validation fails:
+
+ Relay-backed push is configured in `openclaw.json`.
+
+ Set this in gateway config:
+
+ ```json5
+ {
+ gateway: {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ // Optional. Default: 10000
+ timeoutMs: 10000,
+ },
+ },
+ },
+ },
+ }
+ ```
+
+ CLI equivalent:
+
+ ```bash
+ openclaw config set gateway.push.apns.relay.baseUrl https://relay.example.com
+ ```
+
+ What this does:
+
+ - Lets the gateway send `push.test`, wake nudges, and reconnect wakes through the external relay.
+ - Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
+ - Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration.
+ - Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
+ - Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment.
+
+ End-to-end flow:
+
+ 1. Install an official/TestFlight iOS build that was compiled with the same relay base URL.
+ 2. Configure `gateway.push.apns.relay.baseUrl` on the gateway.
+ 3. Pair the iOS app to the gateway and let both node and operator sessions connect.
+ 4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
+ 5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
+
+ Operational notes:
+
+ - If you switch the iOS app to a different gateway, reconnect the app so it can publish a new relay registration bound to that gateway.
+ - If you ship a new iOS build that points at a different relay deployment, the app refreshes its cached relay registration instead of reusing the old relay origin.
+
+ Compatibility note:
+
+ - `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
+ - `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
+
+ See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.
+
+
+
```json5
{
diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md
index 0a2eb5abae5..2653b7b51e1 100644
--- a/docs/platforms/ios.md
+++ b/docs/platforms/ios.md
@@ -49,6 +49,114 @@ openclaw nodes status
openclaw gateway call node.list --params "{}"
```
+## Relay-backed push for official builds
+
+Official distributed iOS builds use the external push relay instead of publishing the raw APNs
+token to the gateway.
+
+Gateway-side requirement:
+
+```json5
+{
+ gateway: {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ },
+ },
+ },
+ },
+}
+```
+
+How the flow works:
+
+- The iOS app registers with the relay using App Attest and the app receipt.
+- The relay returns an opaque relay handle plus a registration-scoped send grant.
+- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
+- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
+- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
+- The gateway relay base URL must match the relay URL baked into the official/TestFlight iOS build.
+- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding.
+
+What the gateway does **not** need for this path:
+
+- No deployment-wide relay token.
+- No direct APNs key for official/TestFlight relay-backed sends.
+
+Expected operator flow:
+
+1. Install the official/TestFlight iOS build.
+2. Set `gateway.push.apns.relay.baseUrl` on the gateway.
+3. Pair the app to the gateway and let it finish connecting.
+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.
+
+Compatibility note:
+
+- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
+
+## Authentication and trust flow
+
+The relay exists to enforce two constraints that direct APNs-on-gateway cannot provide for
+official iOS builds:
+
+- Only genuine OpenClaw iOS builds distributed through Apple can use the hosted relay.
+- A gateway can send relay-backed pushes only for iOS devices that paired with that specific
+ gateway.
+
+Hop by hop:
+
+1. `iOS app -> gateway`
+ - The app first pairs with the gateway through the normal Gateway auth flow.
+ - That gives the app an authenticated node session plus an authenticated operator session.
+ - The operator session is used to call `gateway.identity.get`.
+
+2. `iOS app -> relay`
+ - The app calls the relay registration endpoints over HTTPS.
+ - Registration includes App Attest proof plus the app receipt.
+ - The relay validates the bundle ID, App Attest proof, and Apple receipt, and requires the
+ official/production distribution path.
+ - This is what blocks local Xcode/dev builds from using the hosted relay. A local build may be
+ signed, but it does not satisfy the official Apple distribution proof the relay expects.
+
+3. `gateway identity delegation`
+ - Before relay registration, the app fetches the paired gateway identity from
+ `gateway.identity.get`.
+ - The app includes that gateway identity in the relay registration payload.
+ - The relay returns a relay handle and a registration-scoped send grant that are delegated to
+ that gateway identity.
+
+4. `gateway -> relay`
+ - The gateway stores the relay handle and send grant from `push.apns.register`.
+ - On `push.test`, reconnect wakes, and wake nudges, the gateway signs the send request with its
+ own device identity.
+ - The relay verifies both the stored send grant and the gateway signature against the delegated
+ gateway identity from registration.
+ - Another gateway cannot reuse that stored registration, even if it somehow obtains the handle.
+
+5. `relay -> APNs`
+ - The relay owns the production APNs credentials and the raw APNs token for the official build.
+ - The gateway never stores the raw APNs token for relay-backed official builds.
+ - The relay sends the final push to APNs on behalf of the paired gateway.
+
+Why this design was created:
+
+- To keep production APNs credentials out of user gateways.
+- To avoid storing raw official-build APNs tokens on the gateway.
+- To allow hosted relay usage only for official/TestFlight OpenClaw builds.
+- To prevent one gateway from sending wake pushes to iOS devices owned by a different gateway.
+
+Local/manual builds remain on direct APNs. If you are testing those builds without the relay, the
+gateway still needs direct APNs credentials:
+
+```bash
+export OPENCLAW_APNS_TEAM_ID="TEAMID"
+export OPENCLAW_APNS_KEY_ID="KEYID"
+export OPENCLAW_APNS_PRIVATE_KEY_P8='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
+```
+
## Discovery paths
### Bonjour (LAN)
diff --git a/scripts/ios-beta-prepare.sh b/scripts/ios-beta-prepare.sh
index 1d88add46db..9dd0d891c9e 100755
--- a/scripts/ios-beta-prepare.sh
+++ b/scripts/ios-beta-prepare.sh
@@ -4,11 +4,13 @@ set -euo pipefail
usage() {
cat <<'EOF'
Usage:
- scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID]
+ OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com \
+ scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID]
Prepares local beta-release inputs without touching local signing overrides:
- reads package.json.version and writes apps/ios/build/Version.xcconfig
- writes apps/ios/build/BetaRelease.xcconfig with canonical bundle IDs
+- configures the beta build for relay-backed APNs registration
- regenerates apps/ios/OpenClaw.xcodeproj via xcodegen
EOF
}
@@ -22,6 +24,8 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
BUILD_NUMBER=""
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}"
+PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-}}"
+PUSH_RELAY_BASE_URL_XCCONFIG=""
PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
prepare_build_dir() {
@@ -47,6 +51,31 @@ write_generated_file() {
mv -f "${tmp_file}" "${output_path}"
}
+validate_push_relay_base_url() {
+ local value="$1"
+
+ if [[ "${value}" =~ [[:space:]] ]]; then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: whitespace is not allowed." >&2
+ exit 1
+ fi
+
+ if [[ "${value}" == *'$'* || "${value}" == *'('* || "${value}" == *')'* || "${value}" == *'='* ]]; then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: contains forbidden xcconfig characters." >&2
+ exit 1
+ fi
+
+ if [[ ! "${value}" =~ ^https://[A-Za-z0-9.-]+(:([0-9]{1,5}))?(/[A-Za-z0-9._~!&*+,;:@%/-]*)?$ ]]; then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: expected https://host[:port][/path]." >&2
+ exit 1
+ fi
+
+ local port="${BASH_REMATCH[2]:-}"
+ if [[ -n "${port}" ]] && (( 10#${port} > 65535 )); then
+ echo "Invalid OPENCLAW_PUSH_RELAY_BASE_URL: port must be between 1 and 65535." >&2
+ exit 1
+ fi
+}
+
while [[ $# -gt 0 ]]; do
case "$1" in
--)
@@ -87,6 +116,20 @@ if [[ -z "${TEAM_ID}" ]]; then
exit 1
fi
+if [[ -z "${PUSH_RELAY_BASE_URL}" ]]; then
+ echo "Missing OPENCLAW_PUSH_RELAY_BASE_URL (or IOS_PUSH_RELAY_BASE_URL) for beta relay registration." >&2
+ exit 1
+fi
+
+validate_push_relay_base_url "${PUSH_RELAY_BASE_URL}"
+
+# `.xcconfig` treats `//` as a comment opener. Break the URL with a helper setting
+# so Xcode still resolves it back to `https://...` at build time.
+PUSH_RELAY_BASE_URL_XCCONFIG="$(
+ printf '%s' "${PUSH_RELAY_BASE_URL}" \
+ | sed 's#//#$(OPENCLAW_URL_SLASH)$(OPENCLAW_URL_SLASH)#g'
+)"
+
prepare_build_dir
(
@@ -106,6 +149,11 @@ OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
+OPENCLAW_PUSH_TRANSPORT = relay
+OPENCLAW_PUSH_DISTRIBUTION = official
+OPENCLAW_URL_SLASH = /
+OPENCLAW_PUSH_RELAY_BASE_URL = ${PUSH_RELAY_BASE_URL_XCCONFIG}
+OPENCLAW_PUSH_APNS_ENVIRONMENT = production
EOF
(
diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts
index 3db7f40fe73..1fdaae873cb 100644
--- a/src/config/schema.help.ts
+++ b/src/config/schema.help.ts
@@ -386,6 +386,16 @@ export const FIELD_HELP: Record = {
"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.",
"gateway.controlUi.dangerouslyDisableDeviceAuth":
"Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.",
+ "gateway.push":
+ "Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.",
+ "gateway.push.apns":
+ "APNs delivery settings for iOS devices paired to this gateway. Use relay settings for official/TestFlight builds that register through the external push relay.",
+ "gateway.push.apns.relay":
+ "External relay settings for relay-backed APNs sends. The gateway uses this relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.",
+ "gateway.push.apns.relay.baseUrl":
+ "Base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.",
+ "gateway.push.apns.relay.timeoutMs":
+ "Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.",
"gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.http.endpoints.chatCompletions.maxBodyBytes":
diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts
index 64d1acde778..9d56ff2566c 100644
--- a/src/config/schema.hints.ts
+++ b/src/config/schema.hints.ts
@@ -75,6 +75,7 @@ const FIELD_PLACEHOLDERS: Record = {
"gateway.controlUi.basePath": "/openclaw",
"gateway.controlUi.root": "dist/control-ui",
"gateway.controlUi.allowedOrigins": "https://control.example.com",
+ "gateway.push.apns.relay.baseUrl": "https://relay.example.com",
"channels.mattermost.baseUrl": "https://chat.example.com",
"agents.list[].identity.avatar": "avatars/openclaw.png",
};
diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts
index 82bdc1d87cd..1abfb90d656 100644
--- a/src/config/schema.tags.ts
+++ b/src/config/schema.tags.ts
@@ -41,6 +41,7 @@ const TAG_PRIORITY: Record = {
const TAG_OVERRIDES: Record = {
"gateway.auth.token": ["security", "auth", "access", "network"],
"gateway.auth.password": ["security", "auth", "access", "network"],
+ "gateway.push.apns.relay.baseUrl": ["network", "advanced"],
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
"security",
"access",
diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts
index 422bbc82eed..ea17a1d9d05 100644
--- a/src/config/types.gateway.ts
+++ b/src/config/types.gateway.ts
@@ -347,6 +347,21 @@ export type GatewayHttpConfig = {
securityHeaders?: GatewayHttpSecurityHeadersConfig;
};
+export type GatewayPushApnsRelayConfig = {
+ /** Base HTTPS URL for the external iOS APNs relay service. */
+ baseUrl?: string;
+ /** Timeout in milliseconds for relay send requests (default: 10000). */
+ timeoutMs?: number;
+};
+
+export type GatewayPushApnsConfig = {
+ relay?: GatewayPushApnsRelayConfig;
+};
+
+export type GatewayPushConfig = {
+ apns?: GatewayPushApnsConfig;
+};
+
export type GatewayNodesConfig = {
/** Browser routing policy for node-hosted browser proxies. */
browser?: {
@@ -395,6 +410,7 @@ export type GatewayConfig = {
reload?: GatewayReloadConfig;
tls?: GatewayTlsConfig;
http?: GatewayHttpConfig;
+ push?: GatewayPushConfig;
nodes?: GatewayNodesConfig;
/**
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index c35d1191b6f..1b24eebff4d 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -789,6 +789,23 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
+ push: z
+ .object({
+ apns: z
+ .object({
+ relay: z
+ .object({
+ baseUrl: z.string().optional(),
+ timeoutMs: z.number().int().positive().optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
nodes: z
.object({
browser: z
diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts
index ec8279a1947..f4f57259212 100644
--- a/src/gateway/method-scopes.ts
+++ b/src/gateway/method-scopes.ts
@@ -75,6 +75,7 @@ const METHOD_SCOPE_GROUPS: Record = {
"cron.list",
"cron.status",
"cron.runs",
+ "gateway.identity.get",
"system-presence",
"last-heartbeat",
"node.list",
diff --git a/src/gateway/protocol/push.test.ts b/src/gateway/protocol/push.test.ts
new file mode 100644
index 00000000000..3ad91d68cba
--- /dev/null
+++ b/src/gateway/protocol/push.test.ts
@@ -0,0 +1,22 @@
+import AjvPkg from "ajv";
+import { describe, expect, it } from "vitest";
+import { PushTestResultSchema } from "./schema/push.js";
+
+describe("gateway protocol push schema", () => {
+ const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
+ const ajv = new Ajv({ allErrors: true, strict: false });
+ const validatePushTestResult = ajv.compile(PushTestResultSchema);
+
+ it("accepts push.test results with a transport", () => {
+ expect(
+ validatePushTestResult({
+ ok: true,
+ status: 200,
+ tokenSuffix: "abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "relay",
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/src/gateway/protocol/schema/push.ts b/src/gateway/protocol/schema/push.ts
index ded9bbb44c3..eb8b6212959 100644
--- a/src/gateway/protocol/schema/push.ts
+++ b/src/gateway/protocol/schema/push.ts
@@ -22,6 +22,7 @@ export const PushTestResultSchema = Type.Object(
tokenSuffix: Type.String(),
topic: Type.String(),
environment: ApnsEnvironmentSchema,
+ transport: Type.String({ enum: ["direct", "relay"] }),
},
{ additionalProperties: false },
);
diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts
index 2785eb7957e..205bb633e70 100644
--- a/src/gateway/server-methods-list.ts
+++ b/src/gateway/server-methods-list.ts
@@ -91,6 +91,7 @@ const BASE_METHODS = [
"cron.remove",
"cron.run",
"cron.runs",
+ "gateway.identity.get",
"system-presence",
"system-event",
"send",
diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts
index 1f606e925dc..36d19a9a014 100644
--- a/src/gateway/server-methods/nodes.invoke-wake.test.ts
+++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../protocol/index.js";
-import { nodeHandlers } from "./nodes.js";
+import { maybeWakeNodeWithApns, nodeHandlers } from "./nodes.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
@@ -10,10 +10,13 @@ const mocks = vi.hoisted(() => ({
ok: true,
params: rawParams,
})),
+ clearApnsRegistrationIfCurrent: vi.fn(),
loadApnsRegistration: vi.fn(),
resolveApnsAuthConfigFromEnv: vi.fn(),
+ resolveApnsRelayConfigFromEnv: vi.fn(),
sendApnsBackgroundWake: vi.fn(),
sendApnsAlert: vi.fn(),
+ shouldClearStoredApnsRegistration: vi.fn(() => false),
}));
vi.mock("../../config/config.js", () => ({
@@ -30,10 +33,13 @@ vi.mock("../node-invoke-sanitize.js", () => ({
}));
vi.mock("../../infra/push-apns.js", () => ({
+ clearApnsRegistrationIfCurrent: mocks.clearApnsRegistrationIfCurrent,
loadApnsRegistration: mocks.loadApnsRegistration,
resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv,
+ resolveApnsRelayConfigFromEnv: mocks.resolveApnsRelayConfigFromEnv,
sendApnsBackgroundWake: mocks.sendApnsBackgroundWake,
sendApnsAlert: mocks.sendApnsAlert,
+ shouldClearStoredApnsRegistration: mocks.shouldClearStoredApnsRegistration,
}));
type RespondCall = [
@@ -154,6 +160,7 @@ async function ackPending(nodeId: string, ids: string[]) {
function mockSuccessfulWakeConfig(nodeId: string) {
mocks.loadApnsRegistration.mockResolvedValue({
nodeId,
+ transport: "direct",
token: "abcd1234abcd1234abcd1234abcd1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
@@ -173,6 +180,7 @@ function mockSuccessfulWakeConfig(nodeId: string) {
tokenSuffix: "1234abcd",
topic: "ai.openclaw.ios",
environment: "sandbox",
+ transport: "direct",
});
}
@@ -189,9 +197,12 @@ describe("node.invoke APNs wake path", () => {
({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }),
);
mocks.loadApnsRegistration.mockClear();
+ mocks.clearApnsRegistrationIfCurrent.mockClear();
mocks.resolveApnsAuthConfigFromEnv.mockClear();
+ mocks.resolveApnsRelayConfigFromEnv.mockClear();
mocks.sendApnsBackgroundWake.mockClear();
mocks.sendApnsAlert.mockClear();
+ mocks.shouldClearStoredApnsRegistration.mockReturnValue(false);
});
afterEach(() => {
@@ -215,6 +226,43 @@ describe("node.invoke APNs wake path", () => {
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
});
+ it("does not throttle repeated relay wake attempts when relay config is missing", async () => {
+ mocks.loadApnsRegistration.mockResolvedValue({
+ nodeId: "ios-node-relay-no-auth",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ tokenDebugSuffix: "abcd1234",
+ });
+ mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({
+ ok: false,
+ error: "relay config missing",
+ });
+
+ const first = await maybeWakeNodeWithApns("ios-node-relay-no-auth");
+ const second = await maybeWakeNodeWithApns("ios-node-relay-no-auth");
+
+ expect(first).toMatchObject({
+ available: false,
+ throttled: false,
+ path: "no-auth",
+ apnsReason: "relay config missing",
+ });
+ expect(second).toMatchObject({
+ available: false,
+ throttled: false,
+ path: "no-auth",
+ apnsReason: "relay config missing",
+ });
+ expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(2);
+ expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled();
+ });
+
it("wakes and retries invoke after the node reconnects", async () => {
vi.useFakeTimers();
mockSuccessfulWakeConfig("ios-node-reconnect");
@@ -259,6 +307,152 @@ describe("node.invoke APNs wake path", () => {
expect(call?.[1]).toMatchObject({ ok: true, nodeId: "ios-node-reconnect" });
});
+ it("clears stale registrations after an invalid device token wake failure", async () => {
+ mocks.loadApnsRegistration.mockResolvedValue({
+ nodeId: "ios-node-stale",
+ transport: "direct",
+ token: "abcd1234abcd1234abcd1234abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ });
+ mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({
+ ok: true,
+ value: {
+ teamId: "TEAM123",
+ keyId: "KEY123",
+ privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret
+ },
+ });
+ mocks.sendApnsBackgroundWake.mockResolvedValue({
+ ok: false,
+ status: 400,
+ reason: "BadDeviceToken",
+ tokenSuffix: "1234abcd",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ transport: "direct",
+ });
+ mocks.shouldClearStoredApnsRegistration.mockReturnValue(true);
+
+ const nodeRegistry = {
+ get: vi.fn(() => undefined),
+ invoke: vi.fn().mockResolvedValue({ ok: true }),
+ };
+
+ const respond = await invokeNode({
+ nodeRegistry,
+ requestParams: { nodeId: "ios-node-stale", idempotencyKey: "idem-stale" },
+ });
+
+ const call = respond.mock.calls[0] as RespondCall | undefined;
+ expect(call?.[0]).toBe(false);
+ expect(call?.[2]?.message).toBe("node not connected");
+ expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({
+ nodeId: "ios-node-stale",
+ registration: {
+ nodeId: "ios-node-stale",
+ transport: "direct",
+ token: "abcd1234abcd1234abcd1234abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ },
+ });
+ });
+
+ it("does not clear relay registrations from wake failures", async () => {
+ mocks.loadConfig.mockReturnValue({
+ gateway: {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ },
+ },
+ },
+ });
+ mocks.loadApnsRegistration.mockResolvedValue({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ tokenDebugSuffix: "abcd1234",
+ });
+ mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({
+ ok: true,
+ value: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ });
+ mocks.sendApnsBackgroundWake.mockResolvedValue({
+ ok: false,
+ status: 410,
+ reason: "Unregistered",
+ tokenSuffix: "abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "relay",
+ });
+ mocks.shouldClearStoredApnsRegistration.mockReturnValue(false);
+
+ const nodeRegistry = {
+ get: vi.fn(() => undefined),
+ invoke: vi.fn().mockResolvedValue({ ok: true }),
+ };
+
+ const respond = await invokeNode({
+ nodeRegistry,
+ requestParams: { nodeId: "ios-node-relay", idempotencyKey: "idem-relay" },
+ });
+
+ const call = respond.mock.calls[0] as RespondCall | undefined;
+ expect(call?.[0]).toBe(false);
+ expect(call?.[2]?.message).toBe("node not connected");
+ expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ },
+ },
+ });
+ expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
+ registration: {
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ tokenDebugSuffix: "abcd1234",
+ },
+ result: {
+ ok: false,
+ status: 410,
+ reason: "Unregistered",
+ tokenSuffix: "abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "relay",
+ },
+ });
+ expect(mocks.clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
+ });
+
it("forces one retry wake when the first wake still fails to reconnect", async () => {
vi.useFakeTimers();
mockSuccessfulWakeConfig("ios-node-throttle");
diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts
index fadbb0e3742..7f78809abbb 100644
--- a/src/gateway/server-methods/nodes.ts
+++ b/src/gateway/server-methods/nodes.ts
@@ -10,10 +10,13 @@ import {
verifyNodeToken,
} from "../../infra/node-pairing.js";
import {
+ clearApnsRegistrationIfCurrent,
loadApnsRegistration,
- resolveApnsAuthConfigFromEnv,
sendApnsAlert,
sendApnsBackgroundWake,
+ shouldClearStoredApnsRegistration,
+ resolveApnsAuthConfigFromEnv,
+ resolveApnsRelayConfigFromEnv,
} from "../../infra/push-apns.js";
import {
buildCanvasScopedHostUrl,
@@ -92,6 +95,39 @@ type PendingNodeAction = {
const pendingNodeActionsById = new Map();
+async function resolveDirectNodePushConfig() {
+ const auth = await resolveApnsAuthConfigFromEnv(process.env);
+ return auth.ok
+ ? { ok: true as const, auth: auth.value }
+ : { ok: false as const, error: auth.error };
+}
+
+function resolveRelayNodePushConfig() {
+ const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
+ return relay.ok
+ ? { ok: true as const, relayConfig: relay.value }
+ : { ok: false as const, error: relay.error };
+}
+
+async function clearStaleApnsRegistrationIfNeeded(
+ registration: NonNullable>>,
+ nodeId: string,
+ params: { status: number; reason?: string },
+) {
+ if (
+ !shouldClearStoredApnsRegistration({
+ registration,
+ result: params,
+ })
+ ) {
+ return;
+ }
+ await clearApnsRegistrationIfCurrent({
+ nodeId,
+ registration,
+ });
+}
+
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
if (entry.role === "node") {
return true;
@@ -238,23 +274,43 @@ export async function maybeWakeNodeWithApns(
return withDuration({ available: false, throttled: false, path: "no-registration" });
}
- const auth = await resolveApnsAuthConfigFromEnv(process.env);
- if (!auth.ok) {
- return withDuration({
- available: false,
- throttled: false,
- path: "no-auth",
- apnsReason: auth.error,
+ let wakeResult;
+ if (registration.transport === "relay") {
+ const relay = resolveRelayNodePushConfig();
+ if (!relay.ok) {
+ return withDuration({
+ available: false,
+ throttled: false,
+ path: "no-auth",
+ apnsReason: relay.error,
+ });
+ }
+ state.lastWakeAtMs = Date.now();
+ wakeResult = await sendApnsBackgroundWake({
+ registration,
+ nodeId,
+ wakeReason: opts?.wakeReason ?? "node.invoke",
+ relayConfig: relay.relayConfig,
+ });
+ } else {
+ const auth = await resolveDirectNodePushConfig();
+ if (!auth.ok) {
+ return withDuration({
+ available: false,
+ throttled: false,
+ path: "no-auth",
+ apnsReason: auth.error,
+ });
+ }
+ state.lastWakeAtMs = Date.now();
+ wakeResult = await sendApnsBackgroundWake({
+ registration,
+ nodeId,
+ wakeReason: opts?.wakeReason ?? "node.invoke",
+ auth: auth.auth,
});
}
-
- state.lastWakeAtMs = Date.now();
- const wakeResult = await sendApnsBackgroundWake({
- auth: auth.value,
- registration,
- nodeId,
- wakeReason: opts?.wakeReason ?? "node.invoke",
- });
+ await clearStaleApnsRegistrationIfNeeded(registration, nodeId, wakeResult);
if (!wakeResult.ok) {
return withDuration({
available: true,
@@ -316,24 +372,44 @@ export async function maybeSendNodeWakeNudge(nodeId: string): Promise ({
+ loadConfig: vi.fn(() => ({})),
+}));
+
+vi.mock("../../config/config.js", () => ({
+ loadConfig: mocks.loadConfig,
+}));
+
vi.mock("../../infra/push-apns.js", () => ({
+ clearApnsRegistrationIfCurrent: vi.fn(),
loadApnsRegistration: vi.fn(),
normalizeApnsEnvironment: vi.fn(),
resolveApnsAuthConfigFromEnv: vi.fn(),
+ resolveApnsRelayConfigFromEnv: vi.fn(),
sendApnsAlert: vi.fn(),
+ shouldClearStoredApnsRegistration: vi.fn(),
}));
import {
+ clearApnsRegistrationIfCurrent,
loadApnsRegistration,
normalizeApnsEnvironment,
resolveApnsAuthConfigFromEnv,
+ resolveApnsRelayConfigFromEnv,
sendApnsAlert,
+ shouldClearStoredApnsRegistration,
} from "../../infra/push-apns.js";
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
@@ -46,10 +60,15 @@ function expectInvalidRequestResponse(
describe("push.test handler", () => {
beforeEach(() => {
+ mocks.loadConfig.mockClear();
+ mocks.loadConfig.mockReturnValue({});
vi.mocked(loadApnsRegistration).mockClear();
vi.mocked(normalizeApnsEnvironment).mockClear();
vi.mocked(resolveApnsAuthConfigFromEnv).mockClear();
+ vi.mocked(resolveApnsRelayConfigFromEnv).mockClear();
vi.mocked(sendApnsAlert).mockClear();
+ vi.mocked(clearApnsRegistrationIfCurrent).mockClear();
+ vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
});
it("rejects invalid params", async () => {
@@ -68,6 +87,7 @@ describe("push.test handler", () => {
it("sends push test when registration and auth are available", async () => {
vi.mocked(loadApnsRegistration).mockResolvedValue({
nodeId: "ios-node-1",
+ transport: "direct",
token: "abcd",
topic: "ai.openclaw.ios",
environment: "sandbox",
@@ -88,6 +108,7 @@ describe("push.test handler", () => {
tokenSuffix: "1234abcd",
topic: "ai.openclaw.ios",
environment: "sandbox",
+ transport: "direct",
});
const { respond, invoke } = createInvokeParams({
@@ -102,4 +123,246 @@ describe("push.test handler", () => {
expect(call?.[0]).toBe(true);
expect(call?.[1]).toMatchObject({ ok: true, status: 200 });
});
+
+ it("sends push test through relay registrations", async () => {
+ mocks.loadConfig.mockReturnValue({
+ gateway: {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ },
+ },
+ },
+ });
+ vi.mocked(loadApnsRegistration).mockResolvedValue({
+ nodeId: "ios-node-1",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-1",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ tokenDebugSuffix: "abcd1234",
+ });
+ vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({
+ ok: true,
+ value: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ });
+ vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
+ vi.mocked(sendApnsAlert).mockResolvedValue({
+ ok: true,
+ status: 200,
+ tokenSuffix: "abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "relay",
+ });
+
+ const { respond, invoke } = createInvokeParams({
+ nodeId: "ios-node-1",
+ title: "Wake",
+ body: "Ping",
+ });
+ await invoke();
+
+ expect(resolveApnsAuthConfigFromEnv).not.toHaveBeenCalled();
+ expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(1);
+ expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ },
+ },
+ });
+ expect(sendApnsAlert).toHaveBeenCalledTimes(1);
+ const call = respond.mock.calls[0] as RespondCall | undefined;
+ expect(call?.[0]).toBe(true);
+ expect(call?.[1]).toMatchObject({ ok: true, status: 200, transport: "relay" });
+ });
+
+ it("clears stale registrations after invalid token push-test failures", async () => {
+ vi.mocked(loadApnsRegistration).mockResolvedValue({
+ nodeId: "ios-node-1",
+ transport: "direct",
+ token: "abcd",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ });
+ vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({
+ ok: true,
+ value: {
+ teamId: "TEAM123",
+ keyId: "KEY123",
+ privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret
+ },
+ });
+ vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
+ vi.mocked(sendApnsAlert).mockResolvedValue({
+ ok: false,
+ status: 400,
+ reason: "BadDeviceToken",
+ tokenSuffix: "1234abcd",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ transport: "direct",
+ });
+ vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(true);
+
+ const { invoke } = createInvokeParams({
+ nodeId: "ios-node-1",
+ title: "Wake",
+ body: "Ping",
+ });
+ await invoke();
+
+ expect(clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({
+ nodeId: "ios-node-1",
+ registration: {
+ nodeId: "ios-node-1",
+ transport: "direct",
+ token: "abcd",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ },
+ });
+ });
+
+ it("does not clear relay registrations after invalidation-shaped failures", async () => {
+ vi.mocked(loadApnsRegistration).mockResolvedValue({
+ nodeId: "ios-node-1",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ tokenDebugSuffix: "abcd1234",
+ });
+ vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({
+ ok: true,
+ value: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ });
+ vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
+ vi.mocked(sendApnsAlert).mockResolvedValue({
+ ok: false,
+ status: 410,
+ reason: "Unregistered",
+ tokenSuffix: "abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "relay",
+ });
+ vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
+
+ const { invoke } = createInvokeParams({
+ nodeId: "ios-node-1",
+ title: "Wake",
+ body: "Ping",
+ });
+ await invoke();
+
+ expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
+ registration: {
+ nodeId: "ios-node-1",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ tokenDebugSuffix: "abcd1234",
+ },
+ result: {
+ ok: false,
+ status: 410,
+ reason: "Unregistered",
+ tokenSuffix: "abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "relay",
+ },
+ overrideEnvironment: null,
+ });
+ expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
+ });
+
+ it("does not clear direct registrations when push.test overrides the environment", async () => {
+ vi.mocked(loadApnsRegistration).mockResolvedValue({
+ nodeId: "ios-node-1",
+ transport: "direct",
+ token: "abcd",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ });
+ vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({
+ ok: true,
+ value: {
+ teamId: "TEAM123",
+ keyId: "KEY123",
+ privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret
+ },
+ });
+ vi.mocked(normalizeApnsEnvironment).mockReturnValue("production");
+ vi.mocked(sendApnsAlert).mockResolvedValue({
+ ok: false,
+ status: 400,
+ reason: "BadDeviceToken",
+ tokenSuffix: "1234abcd",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "direct",
+ });
+ vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
+
+ const { invoke } = createInvokeParams({
+ nodeId: "ios-node-1",
+ title: "Wake",
+ body: "Ping",
+ environment: "production",
+ });
+ await invoke();
+
+ expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
+ registration: {
+ nodeId: "ios-node-1",
+ transport: "direct",
+ token: "abcd",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ },
+ result: {
+ ok: false,
+ status: 400,
+ reason: "BadDeviceToken",
+ tokenSuffix: "1234abcd",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ transport: "direct",
+ },
+ overrideEnvironment: "production",
+ });
+ expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
+ });
});
diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts
index 5ce25146bd0..7cdf3125965 100644
--- a/src/gateway/server-methods/push.ts
+++ b/src/gateway/server-methods/push.ts
@@ -1,8 +1,12 @@
+import { loadConfig } from "../../config/config.js";
import {
+ clearApnsRegistrationIfCurrent,
loadApnsRegistration,
normalizeApnsEnvironment,
resolveApnsAuthConfigFromEnv,
+ resolveApnsRelayConfigFromEnv,
sendApnsAlert,
+ shouldClearStoredApnsRegistration,
} from "../../infra/push-apns.js";
import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js";
import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js";
@@ -50,23 +54,55 @@ export const pushHandlers: GatewayRequestHandlers = {
return;
}
- const auth = await resolveApnsAuthConfigFromEnv(process.env);
- if (!auth.ok) {
- respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error));
+ const overrideEnvironment = normalizeApnsEnvironment(params.environment);
+ const result =
+ registration.transport === "direct"
+ ? await (async () => {
+ const auth = await resolveApnsAuthConfigFromEnv(process.env);
+ if (!auth.ok) {
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error));
+ return null;
+ }
+ return await sendApnsAlert({
+ registration: {
+ ...registration,
+ environment: overrideEnvironment ?? registration.environment,
+ },
+ nodeId,
+ title,
+ body,
+ auth: auth.value,
+ });
+ })()
+ : await (async () => {
+ const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
+ if (!relay.ok) {
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error));
+ return null;
+ }
+ return await sendApnsAlert({
+ registration,
+ nodeId,
+ title,
+ body,
+ relayConfig: relay.value,
+ });
+ })();
+ if (!result) {
return;
}
-
- const overrideEnvironment = normalizeApnsEnvironment(params.environment);
- const result = await sendApnsAlert({
- auth: auth.value,
- registration: {
- ...registration,
- environment: overrideEnvironment ?? registration.environment,
- },
- nodeId,
- title,
- body,
- });
+ if (
+ shouldClearStoredApnsRegistration({
+ registration,
+ result,
+ overrideEnvironment,
+ })
+ ) {
+ await clearApnsRegistrationIfCurrent({
+ nodeId,
+ registration,
+ });
+ }
respond(true, result, undefined);
});
},
diff --git a/src/gateway/server-methods/system.ts b/src/gateway/server-methods/system.ts
index 7ee8ac35d7d..99853bcaecf 100644
--- a/src/gateway/server-methods/system.ts
+++ b/src/gateway/server-methods/system.ts
@@ -1,4 +1,8 @@
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
+import {
+ loadOrCreateDeviceIdentity,
+ publicKeyRawBase64UrlFromPem,
+} from "../../infra/device-identity.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js";
@@ -8,6 +12,17 @@ import { broadcastPresenceSnapshot } from "../server/presence-events.js";
import type { GatewayRequestHandlers } from "./types.js";
export const systemHandlers: GatewayRequestHandlers = {
+ "gateway.identity.get": ({ respond }) => {
+ const identity = loadOrCreateDeviceIdentity();
+ respond(
+ true,
+ {
+ deviceId: identity.deviceId,
+ publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
+ },
+ undefined,
+ );
+ },
"last-heartbeat": ({ respond }) => {
respond(true, getLastHeartbeatEvent(), undefined);
},
diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts
index a8885a64a63..07425808cea 100644
--- a/src/gateway/server-node-events.test.ts
+++ b/src/gateway/server-node-events.test.ts
@@ -25,6 +25,14 @@ const buildSessionLookup = (
});
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
+const registerApnsRegistrationMock = vi.hoisted(() => vi.fn());
+const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
+ vi.fn(() => ({
+ deviceId: "gateway-device-1",
+ publicKeyPem: "public",
+ privateKeyPem: "private",
+ })),
+);
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
@@ -43,6 +51,12 @@ vi.mock("../config/config.js", () => ({
vi.mock("../config/sessions.js", () => ({
updateSessionStore: vi.fn(),
}));
+vi.mock("../infra/push-apns.js", () => ({
+ registerApnsRegistration: registerApnsRegistrationMock,
+}));
+vi.mock("../infra/device-identity.js", () => ({
+ loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
+}));
vi.mock("./session-utils.js", () => ({
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
pruneLegacyStoreKeys: vi.fn(),
@@ -58,6 +72,7 @@ import type { HealthSummary } from "../commands/health.js";
import { loadConfig } from "../config/config.js";
import { updateSessionStore } from "../config/sessions.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
+import { registerApnsRegistration } from "../infra/push-apns.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import type { NodeEventContext } from "./server-node-events-types.js";
import { handleNodeEvent } from "./server-node-events.js";
@@ -69,6 +84,7 @@ const loadConfigMock = vi.mocked(loadConfig);
const agentCommandMock = vi.mocked(agentCommand);
const updateSessionStoreMock = vi.mocked(updateSessionStore);
const loadSessionEntryMock = vi.mocked(loadSessionEntry);
+const registerApnsRegistrationVi = vi.mocked(registerApnsRegistration);
function buildCtx(): NodeEventContext {
return {
@@ -97,6 +113,8 @@ describe("node exec events", () => {
beforeEach(() => {
enqueueSystemEventMock.mockClear();
requestHeartbeatNowMock.mockClear();
+ registerApnsRegistrationVi.mockClear();
+ loadOrCreateDeviceIdentityMock.mockClear();
});
it("enqueues exec.started events", async () => {
@@ -255,6 +273,75 @@ describe("node exec events", () => {
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
});
+
+ it("stores direct APNs registrations from node events", async () => {
+ const ctx = buildCtx();
+ await handleNodeEvent(ctx, "node-direct", {
+ event: "push.apns.register",
+ payloadJSON: JSON.stringify({
+ token: "abcd1234abcd1234abcd1234abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ }),
+ });
+
+ expect(registerApnsRegistrationVi).toHaveBeenCalledWith({
+ nodeId: "node-direct",
+ transport: "direct",
+ token: "abcd1234abcd1234abcd1234abcd1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ });
+ });
+
+ it("stores relay APNs registrations from node events", async () => {
+ const ctx = buildCtx();
+ await handleNodeEvent(ctx, "node-relay", {
+ event: "push.apns.register",
+ payloadJSON: JSON.stringify({
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ gatewayDeviceId: "gateway-device-1",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ tokenDebugSuffix: "abcd1234",
+ }),
+ });
+
+ expect(registerApnsRegistrationVi).toHaveBeenCalledWith({
+ nodeId: "node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ tokenDebugSuffix: "abcd1234",
+ });
+ });
+
+ it("rejects relay registrations bound to a different gateway identity", async () => {
+ const ctx = buildCtx();
+ await handleNodeEvent(ctx, "node-relay", {
+ event: "push.apns.register",
+ payloadJSON: JSON.stringify({
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ gatewayDeviceId: "gateway-device-other",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ }),
+ });
+
+ expect(registerApnsRegistrationVi).not.toHaveBeenCalled();
+ });
});
describe("voice transcript events", () => {
diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts
index 3a8ad91c420..169b0040297 100644
--- a/src/gateway/server-node-events.ts
+++ b/src/gateway/server-node-events.ts
@@ -4,11 +4,12 @@ import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
import { agentCommandFromIngress } from "../commands/agent.js";
import { loadConfig } from "../config/config.js";
import { updateSessionStore } from "../config/sessions.js";
+import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
-import { registerApnsToken } from "../infra/push-apns.js";
+import { registerApnsRegistration } from "../infra/push-apns.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { normalizeMainKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
@@ -588,16 +589,41 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
if (!obj) {
return;
}
- const token = typeof obj.token === "string" ? obj.token : "";
+ const transport =
+ typeof obj.transport === "string" ? obj.transport.trim().toLowerCase() : "direct";
const topic = typeof obj.topic === "string" ? obj.topic : "";
const environment = obj.environment;
try {
- await registerApnsToken({
- nodeId,
- token,
- topic,
- environment,
- });
+ if (transport === "relay") {
+ const gatewayDeviceId =
+ typeof obj.gatewayDeviceId === "string" ? obj.gatewayDeviceId.trim() : "";
+ const currentGatewayDeviceId = loadOrCreateDeviceIdentity().deviceId;
+ if (!gatewayDeviceId || gatewayDeviceId !== currentGatewayDeviceId) {
+ ctx.logGateway.warn(
+ `push relay register rejected node=${nodeId}: gateway identity mismatch`,
+ );
+ return;
+ }
+ await registerApnsRegistration({
+ nodeId,
+ transport: "relay",
+ relayHandle: typeof obj.relayHandle === "string" ? obj.relayHandle : "",
+ sendGrant: typeof obj.sendGrant === "string" ? obj.sendGrant : "",
+ installationId: typeof obj.installationId === "string" ? obj.installationId : "",
+ topic,
+ environment,
+ distribution: obj.distribution,
+ tokenDebugSuffix: obj.tokenDebugSuffix,
+ });
+ } else {
+ await registerApnsRegistration({
+ nodeId,
+ transport: "direct",
+ token: typeof obj.token === "string" ? obj.token : "",
+ topic,
+ environment,
+ });
+ }
} catch (err) {
ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`);
}
diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts
index 36b05c00d50..f47e80a9bf6 100644
--- a/src/gateway/tools-invoke-http.test.ts
+++ b/src/gateway/tools-invoke-http.test.ts
@@ -8,6 +8,7 @@ type RunBeforeToolCallHookArgs = Parameters[0];
type RunBeforeToolCallHookResult = Awaited>;
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
+
const hookMocks = vi.hoisted(() => ({
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
runBeforeToolCallHook: vi.fn(
diff --git a/src/infra/json-files.ts b/src/infra/json-files.ts
index 15830e9ad4e..6b758ab8740 100644
--- a/src/infra/json-files.ts
+++ b/src/infra/json-files.ts
@@ -39,7 +39,7 @@ export async function writeTextAtomic(
await fs.mkdir(path.dirname(filePath), mkdirOptions);
const tmp = `${filePath}.${randomUUID()}.tmp`;
try {
- await fs.writeFile(tmp, payload, "utf8");
+ await fs.writeFile(tmp, payload, { encoding: "utf8", mode });
try {
await fs.chmod(tmp, mode);
} catch {
diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts
new file mode 100644
index 00000000000..1b3251e6713
--- /dev/null
+++ b/src/infra/push-apns.relay.ts
@@ -0,0 +1,254 @@
+import { URL } from "node:url";
+import type { GatewayConfig } from "../config/types.gateway.js";
+import {
+ loadOrCreateDeviceIdentity,
+ signDevicePayload,
+ type DeviceIdentity,
+} from "./device-identity.js";
+
+export type ApnsRelayPushType = "alert" | "background";
+
+export type ApnsRelayConfig = {
+ baseUrl: string;
+ timeoutMs: number;
+};
+
+export type ApnsRelayConfigResolution =
+ | { ok: true; value: ApnsRelayConfig }
+ | { ok: false; error: string };
+
+export type ApnsRelayPushResponse = {
+ ok: boolean;
+ status: number;
+ apnsId?: string;
+ reason?: string;
+ environment: "production";
+ tokenSuffix?: string;
+};
+
+export type ApnsRelayRequestSender = (params: {
+ relayConfig: ApnsRelayConfig;
+ sendGrant: string;
+ relayHandle: string;
+ gatewayDeviceId: string;
+ signature: string;
+ signedAtMs: number;
+ bodyJson: string;
+ pushType: ApnsRelayPushType;
+ priority: "10" | "5";
+ payload: object;
+}) => Promise;
+
+const DEFAULT_APNS_RELAY_TIMEOUT_MS = 10_000;
+const GATEWAY_DEVICE_ID_HEADER = "x-openclaw-gateway-device-id";
+const GATEWAY_SIGNATURE_HEADER = "x-openclaw-gateway-signature";
+const GATEWAY_SIGNED_AT_HEADER = "x-openclaw-gateway-signed-at-ms";
+
+function normalizeNonEmptyString(value: string | undefined): string | null {
+ const trimmed = value?.trim() ?? "";
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function normalizeTimeoutMs(value: string | number | undefined): number {
+ const raw =
+ typeof value === "number" ? value : typeof value === "string" ? value.trim() : undefined;
+ if (raw === undefined || raw === "") {
+ return DEFAULT_APNS_RELAY_TIMEOUT_MS;
+ }
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed)) {
+ return DEFAULT_APNS_RELAY_TIMEOUT_MS;
+ }
+ return Math.max(1000, Math.trunc(parsed));
+}
+
+function readAllowHttp(value: string | undefined): boolean {
+ const normalized = value?.trim().toLowerCase();
+ return normalized === "1" || normalized === "true" || normalized === "yes";
+}
+
+function isLoopbackRelayHostname(hostname: string): boolean {
+ const normalized = hostname.trim().toLowerCase();
+ return (
+ normalized === "localhost" ||
+ normalized === "::1" ||
+ normalized === "[::1]" ||
+ /^127(?:\.\d{1,3}){3}$/.test(normalized)
+ );
+}
+
+function parseReason(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
+}
+
+function buildRelayGatewaySignaturePayload(params: {
+ gatewayDeviceId: string;
+ signedAtMs: number;
+ bodyJson: string;
+}): string {
+ return [
+ "openclaw-relay-send-v1",
+ params.gatewayDeviceId.trim(),
+ String(Math.trunc(params.signedAtMs)),
+ params.bodyJson,
+ ].join("\n");
+}
+
+export function resolveApnsRelayConfigFromEnv(
+ env: NodeJS.ProcessEnv = process.env,
+ gatewayConfig?: GatewayConfig,
+): ApnsRelayConfigResolution {
+ const configuredRelay = gatewayConfig?.push?.apns?.relay;
+ const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
+ const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl);
+ const baseUrl = envBaseUrl ?? configBaseUrl;
+ const baseUrlSource = envBaseUrl
+ ? "OPENCLAW_APNS_RELAY_BASE_URL"
+ : "gateway.push.apns.relay.baseUrl";
+ if (!baseUrl) {
+ return {
+ ok: false,
+ error:
+ "APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL",
+ };
+ }
+
+ try {
+ const parsed = new URL(baseUrl);
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+ throw new Error("unsupported protocol");
+ }
+ if (!parsed.hostname) {
+ throw new Error("host required");
+ }
+ if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
+ throw new Error(
+ "http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)",
+ );
+ }
+ if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
+ throw new Error("http relay URLs are limited to loopback hosts");
+ }
+ if (parsed.username || parsed.password) {
+ throw new Error("userinfo is not allowed");
+ }
+ if (parsed.search || parsed.hash) {
+ throw new Error("query and fragment are not allowed");
+ }
+ return {
+ ok: true,
+ value: {
+ baseUrl: parsed.toString().replace(/\/+$/, ""),
+ timeoutMs: normalizeTimeoutMs(
+ env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs,
+ ),
+ },
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ ok: false,
+ error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`,
+ };
+ }
+}
+
+async function sendApnsRelayRequest(params: {
+ relayConfig: ApnsRelayConfig;
+ sendGrant: string;
+ relayHandle: string;
+ gatewayDeviceId: string;
+ signature: string;
+ signedAtMs: number;
+ bodyJson: string;
+ pushType: ApnsRelayPushType;
+ priority: "10" | "5";
+ payload: object;
+}): Promise {
+ const response = await fetch(`${params.relayConfig.baseUrl}/v1/push/send`, {
+ method: "POST",
+ redirect: "manual",
+ headers: {
+ authorization: `Bearer ${params.sendGrant}`,
+ "content-type": "application/json",
+ [GATEWAY_DEVICE_ID_HEADER]: params.gatewayDeviceId,
+ [GATEWAY_SIGNATURE_HEADER]: params.signature,
+ [GATEWAY_SIGNED_AT_HEADER]: String(params.signedAtMs),
+ },
+ body: params.bodyJson,
+ signal: AbortSignal.timeout(params.relayConfig.timeoutMs),
+ });
+ if (response.status >= 300 && response.status < 400) {
+ return {
+ ok: false,
+ status: response.status,
+ reason: "RelayRedirectNotAllowed",
+ environment: "production",
+ };
+ }
+
+ let json: unknown = null;
+ try {
+ json = (await response.json()) as unknown;
+ } catch {
+ json = null;
+ }
+ const body =
+ json && typeof json === "object" && !Array.isArray(json)
+ ? (json as Record)
+ : {};
+
+ const status =
+ typeof body.status === "number" && Number.isFinite(body.status)
+ ? Math.trunc(body.status)
+ : response.status;
+ return {
+ ok: typeof body.ok === "boolean" ? body.ok : response.ok && status >= 200 && status < 300,
+ status,
+ apnsId: parseReason(body.apnsId),
+ reason: parseReason(body.reason),
+ environment: "production",
+ tokenSuffix: parseReason(body.tokenSuffix),
+ };
+}
+
+export async function sendApnsRelayPush(params: {
+ relayConfig: ApnsRelayConfig;
+ sendGrant: string;
+ relayHandle: string;
+ pushType: ApnsRelayPushType;
+ priority: "10" | "5";
+ payload: object;
+ gatewayIdentity?: Pick;
+ requestSender?: ApnsRelayRequestSender;
+}): Promise {
+ const sender = params.requestSender ?? sendApnsRelayRequest;
+ const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity();
+ const signedAtMs = Date.now();
+ const bodyJson = JSON.stringify({
+ relayHandle: params.relayHandle,
+ pushType: params.pushType,
+ priority: Number(params.priority),
+ payload: params.payload,
+ });
+ const signature = signDevicePayload(
+ gatewayIdentity.privateKeyPem,
+ buildRelayGatewaySignaturePayload({
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ signedAtMs,
+ bodyJson,
+ }),
+ );
+ return await sender({
+ relayConfig: params.relayConfig,
+ sendGrant: params.sendGrant,
+ relayHandle: params.relayHandle,
+ gatewayDeviceId: gatewayIdentity.deviceId,
+ signature,
+ signedAtMs,
+ bodyJson,
+ pushType: params.pushType,
+ priority: params.priority,
+ payload: params.payload,
+ });
+}
diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts
index 03c75110861..83da4ae3165 100644
--- a/src/infra/push-apns.test.ts
+++ b/src/infra/push-apns.test.ts
@@ -4,18 +4,44 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
+ deriveDeviceIdFromPublicKey,
+ publicKeyRawBase64UrlFromPem,
+ verifyDeviceSignature,
+} from "./device-identity.js";
+import {
+ clearApnsRegistration,
+ clearApnsRegistrationIfCurrent,
loadApnsRegistration,
normalizeApnsEnvironment,
+ registerApnsRegistration,
registerApnsToken,
resolveApnsAuthConfigFromEnv,
+ resolveApnsRelayConfigFromEnv,
sendApnsAlert,
sendApnsBackgroundWake,
+ shouldClearStoredApnsRegistration,
+ shouldInvalidateApnsRegistration,
} from "./push-apns.js";
+import { sendApnsRelayPush } from "./push-apns.relay.js";
const tempDirs: string[] = [];
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
.privateKey.export({ format: "pem", type: "pkcs8" })
.toString();
+const relayGatewayIdentity = (() => {
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
+ const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
+ const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
+ const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
+ if (!deviceId) {
+ throw new Error("failed to derive test gateway device id");
+ }
+ return {
+ deviceId,
+ publicKey: publicKeyRaw,
+ privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
+ };
+})();
async function makeTempDir(): Promise {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
@@ -24,6 +50,7 @@ async function makeTempDir(): Promise {
}
afterEach(async () => {
+ vi.unstubAllGlobals();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
@@ -46,12 +73,46 @@ describe("push APNs registration store", () => {
const loaded = await loadApnsRegistration("ios-node-1", baseDir);
expect(loaded).not.toBeNull();
expect(loaded?.nodeId).toBe("ios-node-1");
- expect(loaded?.token).toBe("abcd1234abcd1234abcd1234abcd1234");
+ expect(loaded?.transport).toBe("direct");
+ expect(loaded && loaded.transport === "direct" ? loaded.token : null).toBe(
+ "abcd1234abcd1234abcd1234abcd1234",
+ );
expect(loaded?.topic).toBe("ai.openclaw.ios");
expect(loaded?.environment).toBe("sandbox");
expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs);
});
+ it("stores and reloads relay-backed APNs registrations without a raw token", async () => {
+ const baseDir = await makeTempDir();
+ const saved = await registerApnsRegistration({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ tokenDebugSuffix: "abcd1234",
+ baseDir,
+ });
+
+ const loaded = await loadApnsRegistration("ios-node-relay", baseDir);
+ expect(saved.transport).toBe("relay");
+ expect(loaded).toMatchObject({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ tokenDebugSuffix: "abcd1234",
+ });
+ expect(loaded && "token" in loaded).toBe(false);
+ });
+
it("rejects invalid APNs tokens", async () => {
const baseDir = await makeTempDir();
await expect(
@@ -63,6 +124,156 @@ describe("push APNs registration store", () => {
}),
).rejects.toThrow("invalid APNs token");
});
+
+ it("rejects oversized direct APNs registration fields", async () => {
+ const baseDir = await makeTempDir();
+ await expect(
+ registerApnsToken({
+ nodeId: "n".repeat(257),
+ token: "ABCD1234ABCD1234ABCD1234ABCD1234",
+ topic: "ai.openclaw.ios",
+ baseDir,
+ }),
+ ).rejects.toThrow("nodeId required");
+ await expect(
+ registerApnsToken({
+ nodeId: "ios-node-1",
+ token: "A".repeat(513),
+ topic: "ai.openclaw.ios",
+ baseDir,
+ }),
+ ).rejects.toThrow("invalid APNs token");
+ await expect(
+ registerApnsToken({
+ nodeId: "ios-node-1",
+ token: "ABCD1234ABCD1234ABCD1234ABCD1234",
+ topic: "a".repeat(256),
+ baseDir,
+ }),
+ ).rejects.toThrow("topic required");
+ });
+
+ it("rejects relay registrations that do not use production/official values", async () => {
+ const baseDir = await makeTempDir();
+ await expect(
+ registerApnsRegistration({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "staging",
+ distribution: "official",
+ baseDir,
+ }),
+ ).rejects.toThrow("relay registrations must use production environment");
+ await expect(
+ registerApnsRegistration({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "beta",
+ baseDir,
+ }),
+ ).rejects.toThrow("relay registrations must use official distribution");
+ });
+
+ it("rejects oversized relay registration identifiers", async () => {
+ const baseDir = await makeTempDir();
+ const oversized = "x".repeat(257);
+ await expect(
+ registerApnsRegistration({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: oversized,
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ baseDir,
+ }),
+ ).rejects.toThrow("relayHandle too long");
+ await expect(
+ registerApnsRegistration({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: oversized,
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ baseDir,
+ }),
+ ).rejects.toThrow("installationId too long");
+ await expect(
+ registerApnsRegistration({
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "x".repeat(1025),
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ baseDir,
+ }),
+ ).rejects.toThrow("sendGrant too long");
+ });
+
+ it("clears registrations", async () => {
+ const baseDir = await makeTempDir();
+ await registerApnsToken({
+ nodeId: "ios-node-1",
+ token: "ABCD1234ABCD1234ABCD1234ABCD1234",
+ topic: "ai.openclaw.ios",
+ baseDir,
+ });
+
+ await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true);
+ await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull();
+ });
+
+ it("only clears a registration when the stored entry still matches", async () => {
+ vi.useFakeTimers();
+ try {
+ const baseDir = await makeTempDir();
+ vi.setSystemTime(new Date("2026-03-11T00:00:00Z"));
+ const stale = await registerApnsToken({
+ nodeId: "ios-node-1",
+ token: "ABCD1234ABCD1234ABCD1234ABCD1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ baseDir,
+ });
+
+ vi.setSystemTime(new Date("2026-03-11T00:00:01Z"));
+ const fresh = await registerApnsToken({
+ nodeId: "ios-node-1",
+ token: "ABCD1234ABCD1234ABCD1234ABCD1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ baseDir,
+ });
+
+ await expect(
+ clearApnsRegistrationIfCurrent({
+ nodeId: "ios-node-1",
+ registration: stale,
+ baseDir,
+ }),
+ ).resolves.toBe(false);
+ await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
describe("push APNs env config", () => {
@@ -97,6 +308,141 @@ describe("push APNs env config", () => {
}
expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID");
});
+
+ it("resolves APNs relay config from env", () => {
+ const resolved = resolveApnsRelayConfigFromEnv({
+ OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com",
+ OPENCLAW_APNS_RELAY_TIMEOUT_MS: "2500",
+ } as NodeJS.ProcessEnv);
+ expect(resolved).toMatchObject({
+ ok: true,
+ value: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 2500,
+ },
+ });
+ });
+
+ it("resolves APNs relay config from gateway config", () => {
+ const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com/base/",
+ timeoutMs: 2500,
+ },
+ },
+ },
+ });
+ expect(resolved).toMatchObject({
+ ok: true,
+ value: {
+ baseUrl: "https://relay.example.com/base",
+ timeoutMs: 2500,
+ },
+ });
+ });
+
+ it("lets relay env overrides win over gateway config", () => {
+ const resolved = resolveApnsRelayConfigFromEnv(
+ {
+ OPENCLAW_APNS_RELAY_BASE_URL: "https://relay-override.example.com",
+ OPENCLAW_APNS_RELAY_TIMEOUT_MS: "3000",
+ } as NodeJS.ProcessEnv,
+ {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 2500,
+ },
+ },
+ },
+ },
+ );
+ expect(resolved).toMatchObject({
+ ok: true,
+ value: {
+ baseUrl: "https://relay-override.example.com",
+ timeoutMs: 3000,
+ },
+ });
+ });
+
+ it("rejects insecure APNs relay http URLs by default", () => {
+ const resolved = resolveApnsRelayConfigFromEnv({
+ OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com",
+ } as NodeJS.ProcessEnv);
+ expect(resolved).toMatchObject({
+ ok: false,
+ });
+ if (resolved.ok) {
+ return;
+ }
+ expect(resolved.error).toContain("OPENCLAW_APNS_RELAY_ALLOW_HTTP=true");
+ });
+
+ it("allows APNs relay http URLs only when explicitly enabled", () => {
+ const resolved = resolveApnsRelayConfigFromEnv({
+ OPENCLAW_APNS_RELAY_BASE_URL: "http://127.0.0.1:8787",
+ OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true",
+ } as NodeJS.ProcessEnv);
+ expect(resolved).toMatchObject({
+ ok: true,
+ value: {
+ baseUrl: "http://127.0.0.1:8787",
+ timeoutMs: 10_000,
+ },
+ });
+ });
+
+ it("rejects http relay URLs for non-loopback hosts even when explicitly enabled", () => {
+ const resolved = resolveApnsRelayConfigFromEnv({
+ OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com",
+ OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true",
+ } as NodeJS.ProcessEnv);
+ expect(resolved).toMatchObject({
+ ok: false,
+ });
+ if (resolved.ok) {
+ return;
+ }
+ expect(resolved.error).toContain("loopback hosts");
+ });
+
+ it("rejects APNs relay URLs with query, fragment, or userinfo components", () => {
+ const withQuery = resolveApnsRelayConfigFromEnv({
+ OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1",
+ } as NodeJS.ProcessEnv);
+ expect(withQuery.ok).toBe(false);
+ if (!withQuery.ok) {
+ expect(withQuery.error).toContain("query and fragment are not allowed");
+ }
+
+ const withUserinfo = resolveApnsRelayConfigFromEnv({
+ OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path",
+ } as NodeJS.ProcessEnv);
+ expect(withUserinfo.ok).toBe(false);
+ if (!withUserinfo.ok) {
+ expect(withUserinfo.error).toContain("userinfo is not allowed");
+ }
+ });
+
+ it("reports the config key name for invalid gateway relay URLs", () => {
+ const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, {
+ push: {
+ apns: {
+ relay: {
+ baseUrl: "https://relay.example.com/path?debug=1",
+ },
+ },
+ },
+ });
+ expect(resolved.ok).toBe(false);
+ if (!resolved.ok) {
+ expect(resolved.error).toContain("gateway.push.apns.relay.baseUrl");
+ }
+ });
});
describe("push APNs send semantics", () => {
@@ -108,13 +454,9 @@ describe("push APNs send semantics", () => {
});
const result = await sendApnsAlert({
- auth: {
- teamId: "TEAM123",
- keyId: "KEY123",
- privateKey: testAuthPrivateKey,
- },
registration: {
nodeId: "ios-node-alert",
+ transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
@@ -123,6 +465,11 @@ describe("push APNs send semantics", () => {
nodeId: "ios-node-alert",
title: "Wake",
body: "Ping",
+ auth: {
+ teamId: "TEAM123",
+ keyId: "KEY123",
+ privateKey: testAuthPrivateKey,
+ },
requestSender: send,
});
@@ -142,6 +489,7 @@ describe("push APNs send semantics", () => {
});
expect(result.ok).toBe(true);
expect(result.status).toBe(200);
+ expect(result.transport).toBe("direct");
});
it("sends background wake pushes with silent payload semantics", async () => {
@@ -152,13 +500,9 @@ describe("push APNs send semantics", () => {
});
const result = await sendApnsBackgroundWake({
- auth: {
- teamId: "TEAM123",
- keyId: "KEY123",
- privateKey: testAuthPrivateKey,
- },
registration: {
nodeId: "ios-node-wake",
+ transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "production",
@@ -166,6 +510,11 @@ describe("push APNs send semantics", () => {
},
nodeId: "ios-node-wake",
wakeReason: "node.invoke",
+ auth: {
+ teamId: "TEAM123",
+ keyId: "KEY123",
+ privateKey: testAuthPrivateKey,
+ },
requestSender: send,
});
@@ -189,6 +538,7 @@ describe("push APNs send semantics", () => {
expect(aps?.sound).toBeUndefined();
expect(result.ok).toBe(true);
expect(result.environment).toBe("production");
+ expect(result.transport).toBe("direct");
});
it("defaults background wake reason when not provided", async () => {
@@ -199,19 +549,20 @@ describe("push APNs send semantics", () => {
});
await sendApnsBackgroundWake({
- auth: {
- teamId: "TEAM123",
- keyId: "KEY123",
- privateKey: testAuthPrivateKey,
- },
registration: {
nodeId: "ios-node-wake-default-reason",
+ transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
updatedAtMs: 1,
},
nodeId: "ios-node-wake-default-reason",
+ auth: {
+ teamId: "TEAM123",
+ keyId: "KEY123",
+ privateKey: testAuthPrivateKey,
+ },
requestSender: send,
});
@@ -224,4 +575,158 @@ describe("push APNs send semantics", () => {
},
});
});
+
+ it("routes relay-backed alert pushes through the relay sender", async () => {
+ const send = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ apnsId: "relay-apns-id",
+ environment: "production",
+ tokenSuffix: "abcd1234",
+ });
+
+ const result = await sendApnsAlert({
+ relayConfig: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ registration: {
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ tokenDebugSuffix: "abcd1234",
+ },
+ nodeId: "ios-node-relay",
+ title: "Wake",
+ body: "Ping",
+ relayGatewayIdentity: relayGatewayIdentity,
+ relayRequestSender: send,
+ });
+
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send.mock.calls[0]?.[0]).toMatchObject({
+ relayHandle: "relay-handle-123",
+ gatewayDeviceId: relayGatewayIdentity.deviceId,
+ pushType: "alert",
+ priority: "10",
+ payload: {
+ aps: {
+ alert: { title: "Wake", body: "Ping" },
+ sound: "default",
+ },
+ },
+ });
+ const sent = send.mock.calls[0]?.[0];
+ expect(typeof sent?.signature).toBe("string");
+ expect(typeof sent?.signedAtMs).toBe("number");
+ const signedPayload = [
+ "openclaw-relay-send-v1",
+ sent?.gatewayDeviceId,
+ String(sent?.signedAtMs),
+ sent?.bodyJson,
+ ].join("\n");
+ expect(
+ verifyDeviceSignature(relayGatewayIdentity.publicKey, signedPayload, sent?.signature),
+ ).toBe(true);
+ expect(result).toMatchObject({
+ ok: true,
+ status: 200,
+ transport: "relay",
+ environment: "production",
+ tokenSuffix: "abcd1234",
+ });
+ });
+
+ it("does not follow relay redirects", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 302,
+ json: vi.fn().mockRejectedValue(new Error("no body")),
+ });
+ vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
+
+ const result = await sendApnsRelayPush({
+ relayConfig: {
+ baseUrl: "https://relay.example.com",
+ timeoutMs: 1000,
+ },
+ sendGrant: "send-grant-123",
+ relayHandle: "relay-handle-123",
+ payload: { aps: { "content-available": 1 } },
+ pushType: "background",
+ priority: "5",
+ gatewayIdentity: relayGatewayIdentity,
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
+ expect(result).toMatchObject({
+ ok: false,
+ status: 302,
+ reason: "RelayRedirectNotAllowed",
+ environment: "production",
+ });
+ });
+
+ it("flags invalid device responses for registration invalidation", () => {
+ expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true);
+ expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);
+ expect(shouldInvalidateApnsRegistration({ status: 429, reason: "TooManyRequests" })).toBe(
+ false,
+ );
+ });
+
+ it("only clears stored registrations for direct APNs failures without an override mismatch", () => {
+ expect(
+ shouldClearStoredApnsRegistration({
+ registration: {
+ nodeId: "ios-node-direct",
+ transport: "direct",
+ token: "ABCD1234ABCD1234ABCD1234ABCD1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ },
+ result: { status: 400, reason: "BadDeviceToken" },
+ }),
+ ).toBe(true);
+
+ expect(
+ shouldClearStoredApnsRegistration({
+ registration: {
+ nodeId: "ios-node-relay",
+ transport: "relay",
+ relayHandle: "relay-handle-123",
+ sendGrant: "send-grant-123",
+ installationId: "install-123",
+ topic: "ai.openclaw.ios",
+ environment: "production",
+ distribution: "official",
+ updatedAtMs: 1,
+ },
+ result: { status: 410, reason: "Unregistered" },
+ }),
+ ).toBe(false);
+
+ expect(
+ shouldClearStoredApnsRegistration({
+ registration: {
+ nodeId: "ios-node-direct",
+ transport: "direct",
+ token: "ABCD1234ABCD1234ABCD1234ABCD1234",
+ topic: "ai.openclaw.ios",
+ environment: "sandbox",
+ updatedAtMs: 1,
+ },
+ result: { status: 400, reason: "BadDeviceToken" },
+ overrideEnvironment: "production",
+ }),
+ ).toBe(false);
+ });
});
diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts
index 0da3e1f429b..9d67fbcdd2b 100644
--- a/src/infra/push-apns.ts
+++ b/src/infra/push-apns.ts
@@ -3,18 +3,44 @@ import fs from "node:fs/promises";
import http2 from "node:http2";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
+import type { DeviceIdentity } from "./device-identity.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
+import {
+ type ApnsRelayConfig,
+ type ApnsRelayConfigResolution,
+ type ApnsRelayPushResponse,
+ type ApnsRelayRequestSender,
+ resolveApnsRelayConfigFromEnv,
+ sendApnsRelayPush,
+} from "./push-apns.relay.js";
export type ApnsEnvironment = "sandbox" | "production";
+export type ApnsTransport = "direct" | "relay";
-export type ApnsRegistration = {
+export type DirectApnsRegistration = {
nodeId: string;
+ transport: "direct";
token: string;
topic: string;
environment: ApnsEnvironment;
updatedAtMs: number;
};
+export type RelayApnsRegistration = {
+ nodeId: string;
+ transport: "relay";
+ relayHandle: string;
+ sendGrant: string;
+ installationId: string;
+ topic: string;
+ environment: "production";
+ distribution: "official";
+ updatedAtMs: number;
+ tokenDebugSuffix?: string;
+};
+
+export type ApnsRegistration = DirectApnsRegistration | RelayApnsRegistration;
+
export type ApnsAuthConfig = {
teamId: string;
keyId: string;
@@ -25,7 +51,7 @@ export type ApnsAuthConfigResolution =
| { ok: true; value: ApnsAuthConfig }
| { ok: false; error: string };
-export type ApnsPushAlertResult = {
+export type ApnsPushResult = {
ok: boolean;
status: number;
apnsId?: string;
@@ -33,17 +59,11 @@ export type ApnsPushAlertResult = {
tokenSuffix: string;
topic: string;
environment: ApnsEnvironment;
+ transport: ApnsTransport;
};
-export type ApnsPushWakeResult = {
- ok: boolean;
- status: number;
- apnsId?: string;
- reason?: string;
- tokenSuffix: string;
- topic: string;
- environment: ApnsEnvironment;
-};
+export type ApnsPushAlertResult = ApnsPushResult;
+export type ApnsPushWakeResult = ApnsPushResult;
type ApnsPushType = "alert" | "background";
@@ -66,9 +86,38 @@ type ApnsRegistrationState = {
registrationsByNodeId: Record;
};
+type RegisterDirectApnsParams = {
+ nodeId: string;
+ transport?: "direct";
+ token: string;
+ topic: string;
+ environment?: unknown;
+ baseDir?: string;
+};
+
+type RegisterRelayApnsParams = {
+ nodeId: string;
+ transport: "relay";
+ relayHandle: string;
+ sendGrant: string;
+ installationId: string;
+ topic: string;
+ environment?: unknown;
+ distribution?: unknown;
+ tokenDebugSuffix?: unknown;
+ baseDir?: string;
+};
+
+type RegisterApnsParams = RegisterDirectApnsParams | RegisterRelayApnsParams;
+
const APNS_STATE_FILENAME = "push/apns-registrations.json";
const APNS_JWT_TTL_MS = 50 * 60 * 1000;
const DEFAULT_APNS_TIMEOUT_MS = 10_000;
+const MAX_NODE_ID_LENGTH = 256;
+const MAX_TOPIC_LENGTH = 255;
+const MAX_APNS_TOKEN_HEX_LENGTH = 512;
+const MAX_RELAY_IDENTIFIER_LENGTH = 256;
+const MAX_SEND_GRANT_LENGTH = 1024;
const withLock = createAsyncLock();
let cachedJwt: { cacheKey: string; token: string; expiresAtMs: number } | null = null;
@@ -82,6 +131,10 @@ function normalizeNodeId(value: string): string {
return value.trim();
}
+function isValidNodeId(value: string): boolean {
+ return value.length > 0 && value.length <= MAX_NODE_ID_LENGTH;
+}
+
function normalizeApnsToken(value: string): string {
return value
.trim()
@@ -89,12 +142,52 @@ function normalizeApnsToken(value: string): string {
.toLowerCase();
}
+function normalizeRelayHandle(value: string): string {
+ return value.trim();
+}
+
+function normalizeInstallationId(value: string): string {
+ return value.trim();
+}
+
+function validateRelayIdentifier(
+ value: string,
+ fieldName: string,
+ maxLength: number = MAX_RELAY_IDENTIFIER_LENGTH,
+): string {
+ if (!value) {
+ throw new Error(`${fieldName} required`);
+ }
+ if (value.length > maxLength) {
+ throw new Error(`${fieldName} too long`);
+ }
+ if (/[^\x21-\x7e]/.test(value)) {
+ throw new Error(`${fieldName} invalid`);
+ }
+ return value;
+}
+
function normalizeTopic(value: string): string {
return value.trim();
}
+function isValidTopic(value: string): boolean {
+ return value.length > 0 && value.length <= MAX_TOPIC_LENGTH;
+}
+
+function normalizeTokenDebugSuffix(value: unknown): string | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const normalized = value
+ .trim()
+ .toLowerCase()
+ .replace(/[^0-9a-z]/g, "");
+ return normalized.length > 0 ? normalized.slice(-8) : undefined;
+}
+
function isLikelyApnsToken(value: string): boolean {
- return /^[0-9a-f]{32,}$/i.test(value);
+ return value.length <= MAX_APNS_TOKEN_HEX_LENGTH && /^[0-9a-f]{32,}$/i.test(value);
}
function parseReason(body: string): string | undefined {
@@ -161,6 +254,105 @@ function normalizeNonEmptyString(value: string | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
+function normalizeDistribution(value: unknown): "official" | null {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const normalized = value.trim().toLowerCase();
+ return normalized === "official" ? "official" : null;
+}
+
+function normalizeDirectRegistration(
+ record: Partial & { nodeId?: unknown; token?: unknown },
+): DirectApnsRegistration | null {
+ if (typeof record.nodeId !== "string" || typeof record.token !== "string") {
+ return null;
+ }
+ const nodeId = normalizeNodeId(record.nodeId);
+ const token = normalizeApnsToken(record.token);
+ const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : "");
+ const environment = normalizeApnsEnvironment(record.environment) ?? "sandbox";
+ const updatedAtMs =
+ typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs)
+ ? Math.trunc(record.updatedAtMs)
+ : 0;
+ if (!isValidNodeId(nodeId) || !isValidTopic(topic) || !isLikelyApnsToken(token)) {
+ return null;
+ }
+ return {
+ nodeId,
+ transport: "direct",
+ token,
+ topic,
+ environment,
+ updatedAtMs,
+ };
+}
+
+function normalizeRelayRegistration(
+ record: Partial & {
+ nodeId?: unknown;
+ relayHandle?: unknown;
+ sendGrant?: unknown;
+ },
+): RelayApnsRegistration | null {
+ if (
+ typeof record.nodeId !== "string" ||
+ typeof record.relayHandle !== "string" ||
+ typeof record.sendGrant !== "string" ||
+ typeof record.installationId !== "string"
+ ) {
+ return null;
+ }
+ const nodeId = normalizeNodeId(record.nodeId);
+ const relayHandle = normalizeRelayHandle(record.relayHandle);
+ const sendGrant = record.sendGrant.trim();
+ const installationId = normalizeInstallationId(record.installationId);
+ const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : "");
+ const environment = normalizeApnsEnvironment(record.environment);
+ const distribution = normalizeDistribution(record.distribution);
+ const updatedAtMs =
+ typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs)
+ ? Math.trunc(record.updatedAtMs)
+ : 0;
+ if (
+ !isValidNodeId(nodeId) ||
+ !relayHandle ||
+ !sendGrant ||
+ !installationId ||
+ !isValidTopic(topic) ||
+ environment !== "production" ||
+ distribution !== "official"
+ ) {
+ return null;
+ }
+ return {
+ nodeId,
+ transport: "relay",
+ relayHandle,
+ sendGrant,
+ installationId,
+ topic,
+ environment,
+ distribution,
+ updatedAtMs,
+ tokenDebugSuffix: normalizeTokenDebugSuffix(record.tokenDebugSuffix),
+ };
+}
+
+function normalizeStoredRegistration(record: unknown): ApnsRegistration | null {
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
+ return null;
+ }
+ const candidate = record as Record;
+ const transport =
+ typeof candidate.transport === "string" ? candidate.transport.trim().toLowerCase() : "direct";
+ if (transport === "relay") {
+ return normalizeRelayRegistration(candidate as Partial);
+ }
+ return normalizeDirectRegistration(candidate as Partial);
+}
+
async function loadRegistrationsState(baseDir?: string): Promise {
const filePath = resolveApnsRegistrationPath(baseDir);
const existing = await readJsonFile(filePath);
@@ -173,7 +365,16 @@ async function loadRegistrationsState(baseDir?: string): Promise = {};
+ for (const [nodeId, record] of Object.entries(registrations)) {
+ const registration = normalizeStoredRegistration(record);
+ if (registration) {
+ const normalizedNodeId = normalizeNodeId(nodeId);
+ normalized[isValidNodeId(normalizedNodeId) ? normalizedNodeId : registration.nodeId] =
+ registration;
+ }
+ }
+ return { registrationsByNodeId: normalized };
}
async function persistRegistrationsState(
@@ -181,7 +382,11 @@ async function persistRegistrationsState(
baseDir?: string,
): Promise {
const filePath = resolveApnsRegistrationPath(baseDir);
- await writeJsonAtomic(filePath, state);
+ await writeJsonAtomic(filePath, state, {
+ mode: 0o600,
+ ensureDirMode: 0o700,
+ trailingNewline: true,
+ });
}
export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null {
@@ -195,41 +400,90 @@ export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null
return null;
}
+export async function registerApnsRegistration(
+ params: RegisterApnsParams,
+): Promise {
+ const nodeId = normalizeNodeId(params.nodeId);
+ const topic = normalizeTopic(params.topic);
+ if (!isValidNodeId(nodeId)) {
+ throw new Error("nodeId required");
+ }
+ if (!isValidTopic(topic)) {
+ throw new Error("topic required");
+ }
+
+ return await withLock(async () => {
+ const state = await loadRegistrationsState(params.baseDir);
+ const updatedAtMs = Date.now();
+
+ let next: ApnsRegistration;
+ if (params.transport === "relay") {
+ const relayHandle = validateRelayIdentifier(
+ normalizeRelayHandle(params.relayHandle),
+ "relayHandle",
+ );
+ const sendGrant = validateRelayIdentifier(
+ params.sendGrant.trim(),
+ "sendGrant",
+ MAX_SEND_GRANT_LENGTH,
+ );
+ const installationId = validateRelayIdentifier(
+ normalizeInstallationId(params.installationId),
+ "installationId",
+ );
+ const environment = normalizeApnsEnvironment(params.environment);
+ const distribution = normalizeDistribution(params.distribution);
+ if (environment !== "production") {
+ throw new Error("relay registrations must use production environment");
+ }
+ if (distribution !== "official") {
+ throw new Error("relay registrations must use official distribution");
+ }
+ next = {
+ nodeId,
+ transport: "relay",
+ relayHandle,
+ sendGrant,
+ installationId,
+ topic,
+ environment,
+ distribution,
+ updatedAtMs,
+ tokenDebugSuffix: normalizeTokenDebugSuffix(params.tokenDebugSuffix),
+ };
+ } else {
+ const token = normalizeApnsToken(params.token);
+ const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox";
+ if (!isLikelyApnsToken(token)) {
+ throw new Error("invalid APNs token");
+ }
+ next = {
+ nodeId,
+ transport: "direct",
+ token,
+ topic,
+ environment,
+ updatedAtMs,
+ };
+ }
+
+ state.registrationsByNodeId[nodeId] = next;
+ await persistRegistrationsState(state, params.baseDir);
+ return next;
+ });
+}
+
export async function registerApnsToken(params: {
nodeId: string;
token: string;
topic: string;
environment?: unknown;
baseDir?: string;
-}): Promise {
- const nodeId = normalizeNodeId(params.nodeId);
- const token = normalizeApnsToken(params.token);
- const topic = normalizeTopic(params.topic);
- const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox";
-
- if (!nodeId) {
- throw new Error("nodeId required");
- }
- if (!topic) {
- throw new Error("topic required");
- }
- if (!isLikelyApnsToken(token)) {
- throw new Error("invalid APNs token");
- }
-
- return await withLock(async () => {
- const state = await loadRegistrationsState(params.baseDir);
- const next: ApnsRegistration = {
- nodeId,
- token,
- topic,
- environment,
- updatedAtMs: Date.now(),
- };
- state.registrationsByNodeId[nodeId] = next;
- await persistRegistrationsState(state, params.baseDir);
- return next;
- });
+}): Promise {
+ return (await registerApnsRegistration({
+ ...params,
+ transport: "direct",
+ })) as DirectApnsRegistration;
}
export async function loadApnsRegistration(
@@ -244,6 +498,95 @@ export async function loadApnsRegistration(
return state.registrationsByNodeId[normalizedNodeId] ?? null;
}
+export async function clearApnsRegistration(nodeId: string, baseDir?: string): Promise {
+ const normalizedNodeId = normalizeNodeId(nodeId);
+ if (!normalizedNodeId) {
+ return false;
+ }
+ return await withLock(async () => {
+ const state = await loadRegistrationsState(baseDir);
+ if (!(normalizedNodeId in state.registrationsByNodeId)) {
+ return false;
+ }
+ delete state.registrationsByNodeId[normalizedNodeId];
+ await persistRegistrationsState(state, baseDir);
+ return true;
+ });
+}
+
+function isSameApnsRegistration(a: ApnsRegistration, b: ApnsRegistration): boolean {
+ if (
+ a.nodeId !== b.nodeId ||
+ a.transport !== b.transport ||
+ a.topic !== b.topic ||
+ a.environment !== b.environment ||
+ a.updatedAtMs !== b.updatedAtMs
+ ) {
+ return false;
+ }
+ if (a.transport === "direct" && b.transport === "direct") {
+ return a.token === b.token;
+ }
+ if (a.transport === "relay" && b.transport === "relay") {
+ return (
+ a.relayHandle === b.relayHandle &&
+ a.sendGrant === b.sendGrant &&
+ a.installationId === b.installationId &&
+ a.distribution === b.distribution &&
+ a.tokenDebugSuffix === b.tokenDebugSuffix
+ );
+ }
+ return false;
+}
+
+export async function clearApnsRegistrationIfCurrent(params: {
+ nodeId: string;
+ registration: ApnsRegistration;
+ baseDir?: string;
+}): Promise {
+ const normalizedNodeId = normalizeNodeId(params.nodeId);
+ if (!normalizedNodeId) {
+ return false;
+ }
+ return await withLock(async () => {
+ const state = await loadRegistrationsState(params.baseDir);
+ const current = state.registrationsByNodeId[normalizedNodeId];
+ if (!current || !isSameApnsRegistration(current, params.registration)) {
+ return false;
+ }
+ delete state.registrationsByNodeId[normalizedNodeId];
+ await persistRegistrationsState(state, params.baseDir);
+ return true;
+ });
+}
+
+export function shouldInvalidateApnsRegistration(result: {
+ status: number;
+ reason?: string;
+}): boolean {
+ if (result.status === 410) {
+ return true;
+ }
+ return result.status === 400 && result.reason?.trim() === "BadDeviceToken";
+}
+
+export function shouldClearStoredApnsRegistration(params: {
+ registration: ApnsRegistration;
+ result: { status: number; reason?: string };
+ overrideEnvironment?: ApnsEnvironment | null;
+}): boolean {
+ if (params.registration.transport !== "direct") {
+ return false;
+ }
+ if (
+ params.overrideEnvironment &&
+ params.overrideEnvironment !== params.registration.environment
+ ) {
+ return false;
+ }
+ return shouldInvalidateApnsRegistration(params.result);
+}
+
export async function resolveApnsAuthConfigFromEnv(
env: NodeJS.ProcessEnv = process.env,
): Promise {
@@ -386,7 +729,10 @@ function resolveApnsTimeoutMs(timeoutMs: number | undefined): number {
: DEFAULT_APNS_TIMEOUT_MS;
}
-function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: ApnsRegistration }): {
+function resolveDirectSendContext(params: {
+ auth: ApnsAuthConfig;
+ registration: DirectApnsRegistration;
+}): {
token: string;
topic: string;
environment: ApnsEnvironment;
@@ -397,7 +743,7 @@ function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: Ap
throw new Error("invalid APNs token");
}
const topic = normalizeTopic(params.registration.topic);
- if (!topic) {
+ if (!isValidTopic(topic)) {
throw new Error("topic required");
}
return {
@@ -408,24 +754,7 @@ function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: Ap
};
}
-function toApnsPushResult(params: {
- response: ApnsRequestResponse;
- token: string;
- topic: string;
- environment: ApnsEnvironment;
-}): ApnsPushWakeResult {
- return {
- ok: params.response.status === 200,
- status: params.response.status,
- apnsId: params.response.apnsId,
- reason: parseReason(params.response.body),
- tokenSuffix: params.token.slice(-8),
- topic: params.topic,
- environment: params.environment,
- };
-}
-
-function createOpenClawPushMetadata(params: {
+function toPushMetadata(params: {
kind: "push.test" | "node.wake";
nodeId: string;
reason?: string;
@@ -438,16 +767,61 @@ function createOpenClawPushMetadata(params: {
};
}
-async function sendApnsPush(params: {
- auth: ApnsAuthConfig;
+function resolveRegistrationDebugSuffix(
+ registration: ApnsRegistration,
+ relayResult?: Pick,
+): string {
+ if (registration.transport === "direct") {
+ return registration.token.slice(-8);
+ }
+ return (
+ relayResult?.tokenSuffix ?? registration.tokenDebugSuffix ?? registration.relayHandle.slice(-8)
+ );
+}
+
+function toPushResult(params: {
registration: ApnsRegistration;
+ response: ApnsRequestResponse | ApnsRelayPushResponse;
+ tokenSuffix?: string;
+}): ApnsPushResult {
+ const response =
+ "body" in params.response
+ ? {
+ ok: params.response.status === 200,
+ status: params.response.status,
+ apnsId: params.response.apnsId,
+ reason: parseReason(params.response.body),
+ environment: params.registration.environment,
+ tokenSuffix: params.tokenSuffix,
+ }
+ : params.response;
+ return {
+ ok: response.ok,
+ status: response.status,
+ apnsId: response.apnsId,
+ reason: response.reason,
+ tokenSuffix:
+ params.tokenSuffix ??
+ resolveRegistrationDebugSuffix(
+ params.registration,
+ "tokenSuffix" in response ? response : undefined,
+ ),
+ topic: params.registration.topic,
+ environment: params.registration.transport === "relay" ? "production" : response.environment,
+ transport: params.registration.transport,
+ };
+}
+
+async function sendDirectApnsPush(params: {
+ auth: ApnsAuthConfig;
+ registration: DirectApnsRegistration;
payload: object;
timeoutMs?: number;
requestSender?: ApnsRequestSender;
pushType: ApnsPushType;
priority: "10" | "5";
-}): Promise {
- const { token, topic, environment, bearerToken } = resolveApnsSendContext({
+}): Promise {
+ const { token, topic, environment, bearerToken } = resolveDirectSendContext({
auth: params.auth,
registration: params.registration,
});
@@ -462,19 +836,37 @@ async function sendApnsPush(params: {
pushType: params.pushType,
priority: params.priority,
});
- return toApnsPushResult({ response, token, topic, environment });
+ return toPushResult({
+ registration: params.registration,
+ response,
+ tokenSuffix: token.slice(-8),
+ });
}
-export async function sendApnsAlert(params: {
- auth: ApnsAuthConfig;
- registration: ApnsRegistration;
- nodeId: string;
- title: string;
- body: string;
- timeoutMs?: number;
- requestSender?: ApnsRequestSender;
-}): Promise {
- const payload = {
+async function sendRelayApnsPush(params: {
+ relayConfig: ApnsRelayConfig;
+ registration: RelayApnsRegistration;
+ payload: object;
+ pushType: ApnsPushType;
+ priority: "10" | "5";
+ gatewayIdentity?: Pick;
+ requestSender?: ApnsRelayRequestSender;
+}): Promise {
+ const response = await sendApnsRelayPush({
+ relayConfig: params.relayConfig,
+ sendGrant: params.registration.sendGrant,
+ relayHandle: params.registration.relayHandle,
+ payload: params.payload,
+ pushType: params.pushType,
+ priority: params.priority,
+ gatewayIdentity: params.gatewayIdentity,
+ requestSender: params.requestSender,
+ });
+ return toPushResult({ registration: params.registration, response });
+}
+
+function createAlertPayload(params: { nodeId: string; title: string; body: string }): object {
+ return {
aps: {
alert: {
title: params.title,
@@ -482,48 +874,136 @@ export async function sendApnsAlert(params: {
},
sound: "default",
},
- openclaw: createOpenClawPushMetadata({
+ openclaw: toPushMetadata({
kind: "push.test",
nodeId: params.nodeId,
}),
};
-
- return await sendApnsPush({
- auth: params.auth,
- registration: params.registration,
- payload,
- timeoutMs: params.timeoutMs,
- requestSender: params.requestSender,
- pushType: "alert",
- priority: "10",
- });
}
-export async function sendApnsBackgroundWake(params: {
- auth: ApnsAuthConfig;
- registration: ApnsRegistration;
- nodeId: string;
- wakeReason?: string;
- timeoutMs?: number;
- requestSender?: ApnsRequestSender;
-}): Promise {
- const payload = {
+function createBackgroundPayload(params: { nodeId: string; wakeReason?: string }): object {
+ return {
aps: {
"content-available": 1,
},
- openclaw: createOpenClawPushMetadata({
+ openclaw: toPushMetadata({
kind: "node.wake",
reason: params.wakeReason ?? "node.invoke",
nodeId: params.nodeId,
}),
};
- return await sendApnsPush({
- auth: params.auth,
- registration: params.registration,
+}
+
+type ApnsAlertCommonParams = {
+ nodeId: string;
+ title: string;
+ body: string;
+ timeoutMs?: number;
+};
+
+type DirectApnsAlertParams = ApnsAlertCommonParams & {
+ registration: DirectApnsRegistration;
+ auth: ApnsAuthConfig;
+ requestSender?: ApnsRequestSender;
+ relayConfig?: never;
+ relayRequestSender?: never;
+};
+
+type RelayApnsAlertParams = ApnsAlertCommonParams & {
+ registration: RelayApnsRegistration;
+ relayConfig: ApnsRelayConfig;
+ relayRequestSender?: ApnsRelayRequestSender;
+ relayGatewayIdentity?: Pick;
+ auth?: never;
+ requestSender?: never;
+};
+
+type ApnsBackgroundWakeCommonParams = {
+ nodeId: string;
+ wakeReason?: string;
+ timeoutMs?: number;
+};
+
+type DirectApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
+ registration: DirectApnsRegistration;
+ auth: ApnsAuthConfig;
+ requestSender?: ApnsRequestSender;
+ relayConfig?: never;
+ relayRequestSender?: never;
+};
+
+type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
+ registration: RelayApnsRegistration;
+ relayConfig: ApnsRelayConfig;
+ relayRequestSender?: ApnsRelayRequestSender;
+ relayGatewayIdentity?: Pick;
+ auth?: never;
+ requestSender?: never;
+};
+
+export async function sendApnsAlert(
+ params: DirectApnsAlertParams | RelayApnsAlertParams,
+): Promise {
+ const payload = createAlertPayload({
+ nodeId: params.nodeId,
+ title: params.title,
+ body: params.body,
+ });
+
+ if (params.registration.transport === "relay") {
+ const relayParams = params as RelayApnsAlertParams;
+ return await sendRelayApnsPush({
+ relayConfig: relayParams.relayConfig,
+ registration: relayParams.registration,
+ payload,
+ pushType: "alert",
+ priority: "10",
+ gatewayIdentity: relayParams.relayGatewayIdentity,
+ requestSender: relayParams.relayRequestSender,
+ });
+ }
+ const directParams = params as DirectApnsAlertParams;
+ return await sendDirectApnsPush({
+ auth: directParams.auth,
+ registration: directParams.registration,
payload,
- timeoutMs: params.timeoutMs,
- requestSender: params.requestSender,
+ timeoutMs: directParams.timeoutMs,
+ requestSender: directParams.requestSender,
+ pushType: "alert",
+ priority: "10",
+ });
+}
+
+export async function sendApnsBackgroundWake(
+ params: DirectApnsBackgroundWakeParams | RelayApnsBackgroundWakeParams,
+): Promise {
+ const payload = createBackgroundPayload({
+ nodeId: params.nodeId,
+ wakeReason: params.wakeReason,
+ });
+
+ if (params.registration.transport === "relay") {
+ const relayParams = params as RelayApnsBackgroundWakeParams;
+ return await sendRelayApnsPush({
+ relayConfig: relayParams.relayConfig,
+ registration: relayParams.registration,
+ payload,
+ pushType: "background",
+ priority: "5",
+ gatewayIdentity: relayParams.relayGatewayIdentity,
+ requestSender: relayParams.relayRequestSender,
+ });
+ }
+ const directParams = params as DirectApnsBackgroundWakeParams;
+ return await sendDirectApnsPush({
+ auth: directParams.auth,
+ registration: directParams.registration,
+ payload,
+ timeoutMs: directParams.timeoutMs,
+ requestSender: directParams.requestSender,
pushType: "background",
priority: "5",
});
}
+
+export { type ApnsRelayConfig, type ApnsRelayConfigResolution, resolveApnsRelayConfigFromEnv };