feat(push): add iOS APNs relay gateway (#43369)

* feat(push): add ios apns relay gateway

* fix(shared): avoid oslog string concatenation

# Conflicts:
#	apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

* fix(push): harden relay validation and invalidation

* fix(push): persist app attest state before relay registration

* fix(push): harden relay invalidation and url handling

* feat(push): use scoped relay send grants

* feat(push): configure ios relay through gateway config

* feat(push): bind relay registration to gateway identity

* fix(push): tighten ios relay trust flow

* fix(push): bound APNs registration fields (#43369) (thanks @ngutman)
This commit is contained in:
Nimrod Gutman 2026-03-12 18:15:35 +02:00 committed by GitHub
parent 9342739d71
commit b77b7485e0
36 changed files with 3249 additions and 203 deletions

View File

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

View File

@ -62,12 +62,18 @@ Release behavior:
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`. - 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 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`. - 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. - Root `package.json.version` is the only version source for iOS.
- A root version like `2026.3.11-beta.1` becomes: - A root version like `2026.3.11-beta.1` becomes:
- `CFBundleShortVersionString = 2026.3.11` - `CFBundleShortVersionString = 2026.3.11`
- `CFBundleVersion = next TestFlight build number for 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: Archive without upload:
```bash ```bash
@ -91,9 +97,43 @@ pnpm ios:beta -- --build-number 7
- The app calls `registerForRemoteNotifications()` at launch. - The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`. - `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`). - 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. - 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`). - 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) ## What Works Now (Concrete)

View File

@ -66,6 +66,14 @@
<string>OpenClaw uses on-device speech recognition for voice wake.</string> <string>OpenClaw uses on-device speech recognition for voice wake.</string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>OpenClawPushAPNsEnvironment</key>
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
<key>OpenClawPushDistribution</key>
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
<key>OpenClawPushRelayBaseURL</key>
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
<key>OpenClawPushTransport</key>
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View File

@ -12,6 +12,12 @@ import UserNotifications
private struct NotificationCallError: Error, Sendable { private struct NotificationCallError: Error, Sendable {
let message: String let message: String
} }
private struct GatewayRelayIdentityResponse: Decodable {
let deviceId: String
let publicKey: String
}
// Ensures notification requests return promptly even if the system prompt blocks. // Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable { private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
@ -140,6 +146,7 @@ final class NodeAppModel {
private var shareDeliveryTo: String? private var shareDeliveryTo: String?
private var apnsDeviceTokenHex: String? private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String? private var apnsLastRegisteredTokenHex: String?
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var gatewaySession: GatewayNodeSession { self.nodeGateway } var gatewaySession: GatewayNodeSession { self.nodeGateway }
var operatorSession: GatewayNodeSession { self.operatorGateway } var operatorSession: GatewayNodeSession { self.operatorGateway }
private(set) var activeGatewayConnectConfig: GatewayConnectConfig? private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
@ -528,13 +535,6 @@ final class NodeAppModel {
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
private static var apnsEnvironment: String {
#if DEBUG
"sandbox"
#else
"production"
#endif
}
private func refreshBrandingFromGateway() async { private func refreshBrandingFromGateway() async {
do { do {
@ -1189,7 +1189,15 @@ final class NodeAppModel {
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) _ = 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 { 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<T: Sendable>( private func runNotificationCall<T: Sendable>(
timeoutSeconds: Double, timeoutSeconds: Double,
operation: @escaping @Sendable () async throws -> T operation: @escaping @Sendable () async throws -> T
@ -1834,6 +1853,7 @@ private extension NodeAppModel {
await self.refreshBrandingFromGateway() await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway() await self.refreshAgentsFromGateway()
await self.refreshShareRouteFromGateway() await self.refreshShareRouteFromGateway()
await self.registerAPNsTokenIfNeeded()
await self.startVoiceWakeSync() await self.startVoiceWakeSync()
await MainActor.run { LiveActivityManager.shared.handleReconnect() } await MainActor.run { LiveActivityManager.shared.handleReconnect() }
await MainActor.run { self.startGatewayHealthMonitor() } await MainActor.run { self.startGatewayHealthMonitor() }
@ -2479,7 +2499,8 @@ extension NodeAppModel {
else { else {
return return
} }
if token == self.apnsLastRegisteredTokenHex { let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
return return
} }
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
@ -2488,25 +2509,40 @@ extension NodeAppModel {
return return
} }
struct PushRegistrationPayload: Codable {
var token: String
var topic: String
var environment: String
}
let payload = PushRegistrationPayload(
token: token,
topic: topic,
environment: Self.apnsEnvironment)
do { do {
let json = try Self.encodePayload(payload) let gatewayIdentity: PushRelayGatewayIdentity?
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json) 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 self.apnsLastRegisteredTokenHex = token
} catch { } 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 { private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
guard let apsAny = userInfo["aps"] else { return false } guard let apsAny = userInfo["aps"] else { return false }
if let aps = apsAny as? [AnyHashable: Any] { if let aps = apsAny as? [AnyHashable: Any] {

View File

@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge {
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if !granted { return false } if !granted { return false }
let updatedStatus = await self.notificationAuthorizationStatus(center: center) 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) return self.isAuthorizationStatusAllowed(updatedStatus)
case .denied: case .denied:
return false return false

View File

@ -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<T: RawRepresentable>(
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
}

View File

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

View File

@ -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<Void, Error>?
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<T: Decodable>(_ 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
}
}

View File

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

View File

@ -98,6 +98,17 @@ targets:
SUPPORTS_LIVE_ACTIVITIES: YES SUPPORTS_LIVE_ACTIVITIES: YES
ENABLE_APPINTENTS_METADATA: NO ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: 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: info:
path: Sources/Info.plist path: Sources/Info.plist
properties: properties:
@ -131,6 +142,10 @@ targets:
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake. NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
NSSupportsLiveActivities: true NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationPortraitUpsideDown

View File

@ -892,7 +892,8 @@ public actor GatewayChannelActor {
return (id: id, data: data) return (id: id, data: data)
} catch { } catch {
self.logger.error( 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 throw error
} }
} }

View File

@ -2447,6 +2447,14 @@ See [Plugins](/tools/plugin).
// Remove tools from the default HTTP deny list // Remove tools from the default HTTP deny list
allow: ["gateway"], 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://`. - `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. - `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.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. - 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). - 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. - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.

View File

@ -225,6 +225,63 @@ When validation fails:
</Accordion> </Accordion>
<Accordion title="Enable relay-backed push for official iOS builds">
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.
</Accordion>
<Accordion title="Set up heartbeat (periodic check-ins)"> <Accordion title="Set up heartbeat (periodic check-ins)">
```json5 ```json5
{ {

View File

@ -49,6 +49,114 @@ openclaw nodes status
openclaw gateway call node.list --params "{}" 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 ## Discovery paths
### Bonjour (LAN) ### Bonjour (LAN)

View File

@ -4,11 +4,13 @@ set -euo pipefail
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: 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: Prepares local beta-release inputs without touching local signing overrides:
- reads package.json.version and writes apps/ios/build/Version.xcconfig - reads package.json.version and writes apps/ios/build/Version.xcconfig
- writes apps/ios/build/BetaRelease.xcconfig with canonical bundle IDs - 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 - regenerates apps/ios/OpenClaw.xcodeproj via xcodegen
EOF EOF
} }
@ -22,6 +24,8 @@ VERSION_HELPER="${ROOT_DIR}/scripts/ios-write-version-xcconfig.sh"
BUILD_NUMBER="" BUILD_NUMBER=""
TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}" 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)" PACKAGE_VERSION="$(cd "${ROOT_DIR}" && node -p "require('./package.json').version" 2>/dev/null || true)"
prepare_build_dir() { prepare_build_dir() {
@ -47,6 +51,31 @@ write_generated_file() {
mv -f "${tmp_file}" "${output_path}" 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 while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--) --)
@ -87,6 +116,20 @@ if [[ -z "${TEAM_ID}" ]]; then
exit 1 exit 1
fi 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 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_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_APP_PROFILE = OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_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 EOF
( (

View File

@ -386,6 +386,16 @@ export const FIELD_HELP: Record<string, string> = {
"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.", "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": "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.", "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": "gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.http.endpoints.chatCompletions.maxBodyBytes": "gateway.http.endpoints.chatCompletions.maxBodyBytes":

View File

@ -75,6 +75,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.controlUi.basePath": "/openclaw", "gateway.controlUi.basePath": "/openclaw",
"gateway.controlUi.root": "dist/control-ui", "gateway.controlUi.root": "dist/control-ui",
"gateway.controlUi.allowedOrigins": "https://control.example.com", "gateway.controlUi.allowedOrigins": "https://control.example.com",
"gateway.push.apns.relay.baseUrl": "https://relay.example.com",
"channels.mattermost.baseUrl": "https://chat.example.com", "channels.mattermost.baseUrl": "https://chat.example.com",
"agents.list[].identity.avatar": "avatars/openclaw.png", "agents.list[].identity.avatar": "avatars/openclaw.png",
}; };

View File

@ -41,6 +41,7 @@ const TAG_PRIORITY: Record<ConfigTag, number> = {
const TAG_OVERRIDES: Record<string, ConfigTag[]> = { const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.auth.token": ["security", "auth", "access", "network"], "gateway.auth.token": ["security", "auth", "access", "network"],
"gateway.auth.password": ["security", "auth", "access", "network"], "gateway.auth.password": ["security", "auth", "access", "network"],
"gateway.push.apns.relay.baseUrl": ["network", "advanced"],
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [ "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
"security", "security",
"access", "access",

View File

@ -347,6 +347,21 @@ export type GatewayHttpConfig = {
securityHeaders?: GatewayHttpSecurityHeadersConfig; 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 = { export type GatewayNodesConfig = {
/** Browser routing policy for node-hosted browser proxies. */ /** Browser routing policy for node-hosted browser proxies. */
browser?: { browser?: {
@ -395,6 +410,7 @@ export type GatewayConfig = {
reload?: GatewayReloadConfig; reload?: GatewayReloadConfig;
tls?: GatewayTlsConfig; tls?: GatewayTlsConfig;
http?: GatewayHttpConfig; http?: GatewayHttpConfig;
push?: GatewayPushConfig;
nodes?: GatewayNodesConfig; nodes?: GatewayNodesConfig;
/** /**
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection * IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection

View File

@ -789,6 +789,23 @@ export const OpenClawSchema = z
}) })
.strict() .strict()
.optional(), .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 nodes: z
.object({ .object({
browser: z browser: z

View File

@ -75,6 +75,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"cron.list", "cron.list",
"cron.status", "cron.status",
"cron.runs", "cron.runs",
"gateway.identity.get",
"system-presence", "system-presence",
"last-heartbeat", "last-heartbeat",
"node.list", "node.list",

View File

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

View File

@ -22,6 +22,7 @@ export const PushTestResultSchema = Type.Object(
tokenSuffix: Type.String(), tokenSuffix: Type.String(),
topic: Type.String(), topic: Type.String(),
environment: ApnsEnvironmentSchema, environment: ApnsEnvironmentSchema,
transport: Type.String({ enum: ["direct", "relay"] }),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@ -91,6 +91,7 @@ const BASE_METHODS = [
"cron.remove", "cron.remove",
"cron.run", "cron.run",
"cron.runs", "cron.runs",
"gateway.identity.get",
"system-presence", "system-presence",
"system-event", "system-event",
"send", "send",

View File

@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../protocol/index.js"; import { ErrorCodes } from "../protocol/index.js";
import { nodeHandlers } from "./nodes.js"; import { maybeWakeNodeWithApns, nodeHandlers } from "./nodes.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})), loadConfig: vi.fn(() => ({})),
@ -10,10 +10,13 @@ const mocks = vi.hoisted(() => ({
ok: true, ok: true,
params: rawParams, params: rawParams,
})), })),
clearApnsRegistrationIfCurrent: vi.fn(),
loadApnsRegistration: vi.fn(), loadApnsRegistration: vi.fn(),
resolveApnsAuthConfigFromEnv: vi.fn(), resolveApnsAuthConfigFromEnv: vi.fn(),
resolveApnsRelayConfigFromEnv: vi.fn(),
sendApnsBackgroundWake: vi.fn(), sendApnsBackgroundWake: vi.fn(),
sendApnsAlert: vi.fn(), sendApnsAlert: vi.fn(),
shouldClearStoredApnsRegistration: vi.fn(() => false),
})); }));
vi.mock("../../config/config.js", () => ({ vi.mock("../../config/config.js", () => ({
@ -30,10 +33,13 @@ vi.mock("../node-invoke-sanitize.js", () => ({
})); }));
vi.mock("../../infra/push-apns.js", () => ({ vi.mock("../../infra/push-apns.js", () => ({
clearApnsRegistrationIfCurrent: mocks.clearApnsRegistrationIfCurrent,
loadApnsRegistration: mocks.loadApnsRegistration, loadApnsRegistration: mocks.loadApnsRegistration,
resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv, resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv: mocks.resolveApnsRelayConfigFromEnv,
sendApnsBackgroundWake: mocks.sendApnsBackgroundWake, sendApnsBackgroundWake: mocks.sendApnsBackgroundWake,
sendApnsAlert: mocks.sendApnsAlert, sendApnsAlert: mocks.sendApnsAlert,
shouldClearStoredApnsRegistration: mocks.shouldClearStoredApnsRegistration,
})); }));
type RespondCall = [ type RespondCall = [
@ -154,6 +160,7 @@ async function ackPending(nodeId: string, ids: string[]) {
function mockSuccessfulWakeConfig(nodeId: string) { function mockSuccessfulWakeConfig(nodeId: string) {
mocks.loadApnsRegistration.mockResolvedValue({ mocks.loadApnsRegistration.mockResolvedValue({
nodeId, nodeId,
transport: "direct",
token: "abcd1234abcd1234abcd1234abcd1234", token: "abcd1234abcd1234abcd1234abcd1234",
topic: "ai.openclaw.ios", topic: "ai.openclaw.ios",
environment: "sandbox", environment: "sandbox",
@ -173,6 +180,7 @@ function mockSuccessfulWakeConfig(nodeId: string) {
tokenSuffix: "1234abcd", tokenSuffix: "1234abcd",
topic: "ai.openclaw.ios", topic: "ai.openclaw.ios",
environment: "sandbox", environment: "sandbox",
transport: "direct",
}); });
} }
@ -189,9 +197,12 @@ describe("node.invoke APNs wake path", () => {
({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }), ({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }),
); );
mocks.loadApnsRegistration.mockClear(); mocks.loadApnsRegistration.mockClear();
mocks.clearApnsRegistrationIfCurrent.mockClear();
mocks.resolveApnsAuthConfigFromEnv.mockClear(); mocks.resolveApnsAuthConfigFromEnv.mockClear();
mocks.resolveApnsRelayConfigFromEnv.mockClear();
mocks.sendApnsBackgroundWake.mockClear(); mocks.sendApnsBackgroundWake.mockClear();
mocks.sendApnsAlert.mockClear(); mocks.sendApnsAlert.mockClear();
mocks.shouldClearStoredApnsRegistration.mockReturnValue(false);
}); });
afterEach(() => { afterEach(() => {
@ -215,6 +226,43 @@ describe("node.invoke APNs wake path", () => {
expect(nodeRegistry.invoke).not.toHaveBeenCalled(); 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 () => { it("wakes and retries invoke after the node reconnects", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
mockSuccessfulWakeConfig("ios-node-reconnect"); mockSuccessfulWakeConfig("ios-node-reconnect");
@ -259,6 +307,152 @@ describe("node.invoke APNs wake path", () => {
expect(call?.[1]).toMatchObject({ ok: true, nodeId: "ios-node-reconnect" }); 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 () => { it("forces one retry wake when the first wake still fails to reconnect", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
mockSuccessfulWakeConfig("ios-node-throttle"); mockSuccessfulWakeConfig("ios-node-throttle");

View File

@ -10,10 +10,13 @@ import {
verifyNodeToken, verifyNodeToken,
} from "../../infra/node-pairing.js"; } from "../../infra/node-pairing.js";
import { import {
clearApnsRegistrationIfCurrent,
loadApnsRegistration, loadApnsRegistration,
resolveApnsAuthConfigFromEnv,
sendApnsAlert, sendApnsAlert,
sendApnsBackgroundWake, sendApnsBackgroundWake,
shouldClearStoredApnsRegistration,
resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv,
} from "../../infra/push-apns.js"; } from "../../infra/push-apns.js";
import { import {
buildCanvasScopedHostUrl, buildCanvasScopedHostUrl,
@ -92,6 +95,39 @@ type PendingNodeAction = {
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>(); const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
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<Awaited<ReturnType<typeof loadApnsRegistration>>>,
nodeId: string,
params: { status: number; reason?: string },
) {
if (
!shouldClearStoredApnsRegistration({
registration,
result: params,
})
) {
return;
}
await clearApnsRegistrationIfCurrent({
nodeId,
registration,
});
}
function isNodeEntry(entry: { role?: string; roles?: string[] }) { function isNodeEntry(entry: { role?: string; roles?: string[] }) {
if (entry.role === "node") { if (entry.role === "node") {
return true; return true;
@ -238,23 +274,43 @@ export async function maybeWakeNodeWithApns(
return withDuration({ available: false, throttled: false, path: "no-registration" }); return withDuration({ available: false, throttled: false, path: "no-registration" });
} }
const auth = await resolveApnsAuthConfigFromEnv(process.env); let wakeResult;
if (!auth.ok) { if (registration.transport === "relay") {
return withDuration({ const relay = resolveRelayNodePushConfig();
available: false, if (!relay.ok) {
throttled: false, return withDuration({
path: "no-auth", available: false,
apnsReason: auth.error, 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,
}); });
} }
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, wakeResult);
state.lastWakeAtMs = Date.now();
const wakeResult = await sendApnsBackgroundWake({
auth: auth.value,
registration,
nodeId,
wakeReason: opts?.wakeReason ?? "node.invoke",
});
if (!wakeResult.ok) { if (!wakeResult.ok) {
return withDuration({ return withDuration({
available: true, available: true,
@ -316,24 +372,44 @@ export async function maybeSendNodeWakeNudge(nodeId: string): Promise<NodeWakeNu
if (!registration) { if (!registration) {
return withDuration({ sent: false, throttled: false, reason: "no-registration" }); return withDuration({ sent: false, throttled: false, reason: "no-registration" });
} }
const auth = await resolveApnsAuthConfigFromEnv(process.env);
if (!auth.ok) {
return withDuration({
sent: false,
throttled: false,
reason: "no-auth",
apnsReason: auth.error,
});
}
try { try {
const result = await sendApnsAlert({ let result;
auth: auth.value, if (registration.transport === "relay") {
registration, const relay = resolveRelayNodePushConfig();
nodeId, if (!relay.ok) {
title: "OpenClaw needs a quick reopen", return withDuration({
body: "Tap to reopen OpenClaw and restore the node connection.", sent: false,
}); throttled: false,
reason: "no-auth",
apnsReason: relay.error,
});
}
result = await sendApnsAlert({
registration,
nodeId,
title: "OpenClaw needs a quick reopen",
body: "Tap to reopen OpenClaw and restore the node connection.",
relayConfig: relay.relayConfig,
});
} else {
const auth = await resolveDirectNodePushConfig();
if (!auth.ok) {
return withDuration({
sent: false,
throttled: false,
reason: "no-auth",
apnsReason: auth.error,
});
}
result = await sendApnsAlert({
registration,
nodeId,
title: "OpenClaw needs a quick reopen",
body: "Tap to reopen OpenClaw and restore the node connection.",
auth: auth.auth,
});
}
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, result);
if (!result.ok) { if (!result.ok) {
return withDuration({ return withDuration({
sent: false, sent: false,

View File

@ -2,18 +2,32 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../protocol/index.js"; import { ErrorCodes } from "../protocol/index.js";
import { pushHandlers } from "./push.js"; import { pushHandlers } from "./push.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: mocks.loadConfig,
}));
vi.mock("../../infra/push-apns.js", () => ({ vi.mock("../../infra/push-apns.js", () => ({
clearApnsRegistrationIfCurrent: vi.fn(),
loadApnsRegistration: vi.fn(), loadApnsRegistration: vi.fn(),
normalizeApnsEnvironment: vi.fn(), normalizeApnsEnvironment: vi.fn(),
resolveApnsAuthConfigFromEnv: vi.fn(), resolveApnsAuthConfigFromEnv: vi.fn(),
resolveApnsRelayConfigFromEnv: vi.fn(),
sendApnsAlert: vi.fn(), sendApnsAlert: vi.fn(),
shouldClearStoredApnsRegistration: vi.fn(),
})); }));
import { import {
clearApnsRegistrationIfCurrent,
loadApnsRegistration, loadApnsRegistration,
normalizeApnsEnvironment, normalizeApnsEnvironment,
resolveApnsAuthConfigFromEnv, resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv,
sendApnsAlert, sendApnsAlert,
shouldClearStoredApnsRegistration,
} from "../../infra/push-apns.js"; } from "../../infra/push-apns.js";
type RespondCall = [boolean, unknown?, { code: number; message: string }?]; type RespondCall = [boolean, unknown?, { code: number; message: string }?];
@ -46,10 +60,15 @@ function expectInvalidRequestResponse(
describe("push.test handler", () => { describe("push.test handler", () => {
beforeEach(() => { beforeEach(() => {
mocks.loadConfig.mockClear();
mocks.loadConfig.mockReturnValue({});
vi.mocked(loadApnsRegistration).mockClear(); vi.mocked(loadApnsRegistration).mockClear();
vi.mocked(normalizeApnsEnvironment).mockClear(); vi.mocked(normalizeApnsEnvironment).mockClear();
vi.mocked(resolveApnsAuthConfigFromEnv).mockClear(); vi.mocked(resolveApnsAuthConfigFromEnv).mockClear();
vi.mocked(resolveApnsRelayConfigFromEnv).mockClear();
vi.mocked(sendApnsAlert).mockClear(); vi.mocked(sendApnsAlert).mockClear();
vi.mocked(clearApnsRegistrationIfCurrent).mockClear();
vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
}); });
it("rejects invalid params", async () => { it("rejects invalid params", async () => {
@ -68,6 +87,7 @@ describe("push.test handler", () => {
it("sends push test when registration and auth are available", async () => { it("sends push test when registration and auth are available", async () => {
vi.mocked(loadApnsRegistration).mockResolvedValue({ vi.mocked(loadApnsRegistration).mockResolvedValue({
nodeId: "ios-node-1", nodeId: "ios-node-1",
transport: "direct",
token: "abcd", token: "abcd",
topic: "ai.openclaw.ios", topic: "ai.openclaw.ios",
environment: "sandbox", environment: "sandbox",
@ -88,6 +108,7 @@ describe("push.test handler", () => {
tokenSuffix: "1234abcd", tokenSuffix: "1234abcd",
topic: "ai.openclaw.ios", topic: "ai.openclaw.ios",
environment: "sandbox", environment: "sandbox",
transport: "direct",
}); });
const { respond, invoke } = createInvokeParams({ const { respond, invoke } = createInvokeParams({
@ -102,4 +123,246 @@ describe("push.test handler", () => {
expect(call?.[0]).toBe(true); expect(call?.[0]).toBe(true);
expect(call?.[1]).toMatchObject({ ok: true, status: 200 }); 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();
});
}); });

View File

@ -1,8 +1,12 @@
import { loadConfig } from "../../config/config.js";
import { import {
clearApnsRegistrationIfCurrent,
loadApnsRegistration, loadApnsRegistration,
normalizeApnsEnvironment, normalizeApnsEnvironment,
resolveApnsAuthConfigFromEnv, resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv,
sendApnsAlert, sendApnsAlert,
shouldClearStoredApnsRegistration,
} from "../../infra/push-apns.js"; } from "../../infra/push-apns.js";
import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js"; import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js";
import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js"; import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js";
@ -50,23 +54,55 @@ export const pushHandlers: GatewayRequestHandlers = {
return; return;
} }
const auth = await resolveApnsAuthConfigFromEnv(process.env); const overrideEnvironment = normalizeApnsEnvironment(params.environment);
if (!auth.ok) { const result =
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error)); 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; return;
} }
if (
const overrideEnvironment = normalizeApnsEnvironment(params.environment); shouldClearStoredApnsRegistration({
const result = await sendApnsAlert({ registration,
auth: auth.value, result,
registration: { overrideEnvironment,
...registration, })
environment: overrideEnvironment ?? registration.environment, ) {
}, await clearApnsRegistrationIfCurrent({
nodeId, nodeId,
title, registration,
body, });
}); }
respond(true, result, undefined); respond(true, result, undefined);
}); });
}, },

View File

@ -1,4 +1,8 @@
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js"; import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../../infra/device-identity.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js"; import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js"; import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.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"; import type { GatewayRequestHandlers } from "./types.js";
export const systemHandlers: GatewayRequestHandlers = { export const systemHandlers: GatewayRequestHandlers = {
"gateway.identity.get": ({ respond }) => {
const identity = loadOrCreateDeviceIdentity();
respond(
true,
{
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
},
undefined,
);
},
"last-heartbeat": ({ respond }) => { "last-heartbeat": ({ respond }) => {
respond(true, getLastHeartbeatEvent(), undefined); respond(true, getLastHeartbeatEvent(), undefined);
}, },

View File

@ -25,6 +25,14 @@ const buildSessionLookup = (
}); });
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); 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", () => ({ vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(), enqueueSystemEvent: vi.fn(),
@ -43,6 +51,12 @@ vi.mock("../config/config.js", () => ({
vi.mock("../config/sessions.js", () => ({ vi.mock("../config/sessions.js", () => ({
updateSessionStore: vi.fn(), updateSessionStore: vi.fn(),
})); }));
vi.mock("../infra/push-apns.js", () => ({
registerApnsRegistration: registerApnsRegistrationMock,
}));
vi.mock("../infra/device-identity.js", () => ({
loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
}));
vi.mock("./session-utils.js", () => ({ vi.mock("./session-utils.js", () => ({
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)), loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
pruneLegacyStoreKeys: vi.fn(), pruneLegacyStoreKeys: vi.fn(),
@ -58,6 +72,7 @@ import type { HealthSummary } from "../commands/health.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { updateSessionStore } from "../config/sessions.js"; import { updateSessionStore } from "../config/sessions.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { registerApnsRegistration } from "../infra/push-apns.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import type { NodeEventContext } from "./server-node-events-types.js"; import type { NodeEventContext } from "./server-node-events-types.js";
import { handleNodeEvent } from "./server-node-events.js"; import { handleNodeEvent } from "./server-node-events.js";
@ -69,6 +84,7 @@ const loadConfigMock = vi.mocked(loadConfig);
const agentCommandMock = vi.mocked(agentCommand); const agentCommandMock = vi.mocked(agentCommand);
const updateSessionStoreMock = vi.mocked(updateSessionStore); const updateSessionStoreMock = vi.mocked(updateSessionStore);
const loadSessionEntryMock = vi.mocked(loadSessionEntry); const loadSessionEntryMock = vi.mocked(loadSessionEntry);
const registerApnsRegistrationVi = vi.mocked(registerApnsRegistration);
function buildCtx(): NodeEventContext { function buildCtx(): NodeEventContext {
return { return {
@ -97,6 +113,8 @@ describe("node exec events", () => {
beforeEach(() => { beforeEach(() => {
enqueueSystemEventMock.mockClear(); enqueueSystemEventMock.mockClear();
requestHeartbeatNowMock.mockClear(); requestHeartbeatNowMock.mockClear();
registerApnsRegistrationVi.mockClear();
loadOrCreateDeviceIdentityMock.mockClear();
}); });
it("enqueues exec.started events", async () => { it("enqueues exec.started events", async () => {
@ -255,6 +273,75 @@ describe("node exec events", () => {
expect(enqueueSystemEventMock).not.toHaveBeenCalled(); expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(requestHeartbeatNowMock).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", () => { describe("voice transcript events", () => {

View File

@ -4,11 +4,12 @@ import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
import { agentCommandFromIngress } from "../commands/agent.js"; import { agentCommandFromIngress } from "../commands/agent.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { updateSessionStore } from "../config/sessions.js"; import { updateSessionStore } from "../config/sessions.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; import { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.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 { enqueueSystemEvent } from "../infra/system-events.js";
import { normalizeMainKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js"; import { normalizeMainKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@ -588,16 +589,41 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
if (!obj) { if (!obj) {
return; 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 topic = typeof obj.topic === "string" ? obj.topic : "";
const environment = obj.environment; const environment = obj.environment;
try { try {
await registerApnsToken({ if (transport === "relay") {
nodeId, const gatewayDeviceId =
token, typeof obj.gatewayDeviceId === "string" ? obj.gatewayDeviceId.trim() : "";
topic, const currentGatewayDeviceId = loadOrCreateDeviceIdentity().deviceId;
environment, 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) { } catch (err) {
ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`); ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`);
} }

View File

@ -8,6 +8,7 @@ type RunBeforeToolCallHookArgs = Parameters<RunBeforeToolCallHook>[0];
type RunBeforeToolCallHookResult = Awaited<ReturnType<RunBeforeToolCallHook>>; type RunBeforeToolCallHookResult = Awaited<ReturnType<RunBeforeToolCallHook>>;
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
const hookMocks = vi.hoisted(() => ({ const hookMocks = vi.hoisted(() => ({
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })), resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
runBeforeToolCallHook: vi.fn( runBeforeToolCallHook: vi.fn(

View File

@ -39,7 +39,7 @@ export async function writeTextAtomic(
await fs.mkdir(path.dirname(filePath), mkdirOptions); await fs.mkdir(path.dirname(filePath), mkdirOptions);
const tmp = `${filePath}.${randomUUID()}.tmp`; const tmp = `${filePath}.${randomUUID()}.tmp`;
try { try {
await fs.writeFile(tmp, payload, "utf8"); await fs.writeFile(tmp, payload, { encoding: "utf8", mode });
try { try {
await fs.chmod(tmp, mode); await fs.chmod(tmp, mode);
} catch { } catch {

View File

@ -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<ApnsRelayPushResponse>;
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<ApnsRelayPushResponse> {
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<string, unknown>)
: {};
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<DeviceIdentity, "deviceId" | "privateKeyPem">;
requestSender?: ApnsRelayRequestSender;
}): Promise<ApnsRelayPushResponse> {
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,
});
}

View File

@ -4,18 +4,44 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
deriveDeviceIdFromPublicKey,
publicKeyRawBase64UrlFromPem,
verifyDeviceSignature,
} from "./device-identity.js";
import {
clearApnsRegistration,
clearApnsRegistrationIfCurrent,
loadApnsRegistration, loadApnsRegistration,
normalizeApnsEnvironment, normalizeApnsEnvironment,
registerApnsRegistration,
registerApnsToken, registerApnsToken,
resolveApnsAuthConfigFromEnv, resolveApnsAuthConfigFromEnv,
resolveApnsRelayConfigFromEnv,
sendApnsAlert, sendApnsAlert,
sendApnsBackgroundWake, sendApnsBackgroundWake,
shouldClearStoredApnsRegistration,
shouldInvalidateApnsRegistration,
} from "./push-apns.js"; } from "./push-apns.js";
import { sendApnsRelayPush } from "./push-apns.relay.js";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" }) const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
.privateKey.export({ format: "pem", type: "pkcs8" }) .privateKey.export({ format: "pem", type: "pkcs8" })
.toString(); .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<string> { async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
@ -24,6 +50,7 @@ async function makeTempDir(): Promise<string> {
} }
afterEach(async () => { afterEach(async () => {
vi.unstubAllGlobals();
while (tempDirs.length > 0) { while (tempDirs.length > 0) {
const dir = tempDirs.pop(); const dir = tempDirs.pop();
if (dir) { if (dir) {
@ -46,12 +73,46 @@ describe("push APNs registration store", () => {
const loaded = await loadApnsRegistration("ios-node-1", baseDir); const loaded = await loadApnsRegistration("ios-node-1", baseDir);
expect(loaded).not.toBeNull(); expect(loaded).not.toBeNull();
expect(loaded?.nodeId).toBe("ios-node-1"); 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?.topic).toBe("ai.openclaw.ios");
expect(loaded?.environment).toBe("sandbox"); expect(loaded?.environment).toBe("sandbox");
expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs); 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 () => { it("rejects invalid APNs tokens", async () => {
const baseDir = await makeTempDir(); const baseDir = await makeTempDir();
await expect( await expect(
@ -63,6 +124,156 @@ describe("push APNs registration store", () => {
}), }),
).rejects.toThrow("invalid APNs token"); ).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", () => { describe("push APNs env config", () => {
@ -97,6 +308,141 @@ describe("push APNs env config", () => {
} }
expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID"); 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", () => { describe("push APNs send semantics", () => {
@ -108,13 +454,9 @@ describe("push APNs send semantics", () => {
}); });
const result = await sendApnsAlert({ const result = await sendApnsAlert({
auth: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: testAuthPrivateKey,
},
registration: { registration: {
nodeId: "ios-node-alert", nodeId: "ios-node-alert",
transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234", token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios", topic: "ai.openclaw.ios",
environment: "sandbox", environment: "sandbox",
@ -123,6 +465,11 @@ describe("push APNs send semantics", () => {
nodeId: "ios-node-alert", nodeId: "ios-node-alert",
title: "Wake", title: "Wake",
body: "Ping", body: "Ping",
auth: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: testAuthPrivateKey,
},
requestSender: send, requestSender: send,
}); });
@ -142,6 +489,7 @@ describe("push APNs send semantics", () => {
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.status).toBe(200); expect(result.status).toBe(200);
expect(result.transport).toBe("direct");
}); });
it("sends background wake pushes with silent payload semantics", async () => { it("sends background wake pushes with silent payload semantics", async () => {
@ -152,13 +500,9 @@ describe("push APNs send semantics", () => {
}); });
const result = await sendApnsBackgroundWake({ const result = await sendApnsBackgroundWake({
auth: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: testAuthPrivateKey,
},
registration: { registration: {
nodeId: "ios-node-wake", nodeId: "ios-node-wake",
transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234", token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios", topic: "ai.openclaw.ios",
environment: "production", environment: "production",
@ -166,6 +510,11 @@ describe("push APNs send semantics", () => {
}, },
nodeId: "ios-node-wake", nodeId: "ios-node-wake",
wakeReason: "node.invoke", wakeReason: "node.invoke",
auth: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: testAuthPrivateKey,
},
requestSender: send, requestSender: send,
}); });
@ -189,6 +538,7 @@ describe("push APNs send semantics", () => {
expect(aps?.sound).toBeUndefined(); expect(aps?.sound).toBeUndefined();
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.environment).toBe("production"); expect(result.environment).toBe("production");
expect(result.transport).toBe("direct");
}); });
it("defaults background wake reason when not provided", async () => { it("defaults background wake reason when not provided", async () => {
@ -199,19 +549,20 @@ describe("push APNs send semantics", () => {
}); });
await sendApnsBackgroundWake({ await sendApnsBackgroundWake({
auth: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: testAuthPrivateKey,
},
registration: { registration: {
nodeId: "ios-node-wake-default-reason", nodeId: "ios-node-wake-default-reason",
transport: "direct",
token: "ABCD1234ABCD1234ABCD1234ABCD1234", token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios", topic: "ai.openclaw.ios",
environment: "sandbox", environment: "sandbox",
updatedAtMs: 1, updatedAtMs: 1,
}, },
nodeId: "ios-node-wake-default-reason", nodeId: "ios-node-wake-default-reason",
auth: {
teamId: "TEAM123",
keyId: "KEY123",
privateKey: testAuthPrivateKey,
},
requestSender: send, 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);
});
}); });

View File

@ -3,18 +3,44 @@ import fs from "node:fs/promises";
import http2 from "node:http2"; import http2 from "node:http2";
import path from "node:path"; import path from "node:path";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import type { DeviceIdentity } from "./device-identity.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.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 ApnsEnvironment = "sandbox" | "production";
export type ApnsTransport = "direct" | "relay";
export type ApnsRegistration = { export type DirectApnsRegistration = {
nodeId: string; nodeId: string;
transport: "direct";
token: string; token: string;
topic: string; topic: string;
environment: ApnsEnvironment; environment: ApnsEnvironment;
updatedAtMs: number; 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 = { export type ApnsAuthConfig = {
teamId: string; teamId: string;
keyId: string; keyId: string;
@ -25,7 +51,7 @@ export type ApnsAuthConfigResolution =
| { ok: true; value: ApnsAuthConfig } | { ok: true; value: ApnsAuthConfig }
| { ok: false; error: string }; | { ok: false; error: string };
export type ApnsPushAlertResult = { export type ApnsPushResult = {
ok: boolean; ok: boolean;
status: number; status: number;
apnsId?: string; apnsId?: string;
@ -33,17 +59,11 @@ export type ApnsPushAlertResult = {
tokenSuffix: string; tokenSuffix: string;
topic: string; topic: string;
environment: ApnsEnvironment; environment: ApnsEnvironment;
transport: ApnsTransport;
}; };
export type ApnsPushWakeResult = { export type ApnsPushAlertResult = ApnsPushResult;
ok: boolean; export type ApnsPushWakeResult = ApnsPushResult;
status: number;
apnsId?: string;
reason?: string;
tokenSuffix: string;
topic: string;
environment: ApnsEnvironment;
};
type ApnsPushType = "alert" | "background"; type ApnsPushType = "alert" | "background";
@ -66,9 +86,38 @@ type ApnsRegistrationState = {
registrationsByNodeId: Record<string, ApnsRegistration>; registrationsByNodeId: Record<string, ApnsRegistration>;
}; };
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_STATE_FILENAME = "push/apns-registrations.json";
const APNS_JWT_TTL_MS = 50 * 60 * 1000; const APNS_JWT_TTL_MS = 50 * 60 * 1000;
const DEFAULT_APNS_TIMEOUT_MS = 10_000; 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(); const withLock = createAsyncLock();
let cachedJwt: { cacheKey: string; token: string; expiresAtMs: number } | null = null; let cachedJwt: { cacheKey: string; token: string; expiresAtMs: number } | null = null;
@ -82,6 +131,10 @@ function normalizeNodeId(value: string): string {
return value.trim(); return value.trim();
} }
function isValidNodeId(value: string): boolean {
return value.length > 0 && value.length <= MAX_NODE_ID_LENGTH;
}
function normalizeApnsToken(value: string): string { function normalizeApnsToken(value: string): string {
return value return value
.trim() .trim()
@ -89,12 +142,52 @@ function normalizeApnsToken(value: string): string {
.toLowerCase(); .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 { function normalizeTopic(value: string): string {
return value.trim(); 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 { 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 { function parseReason(body: string): string | undefined {
@ -161,6 +254,105 @@ function normalizeNonEmptyString(value: string | undefined): string | null {
return trimmed.length > 0 ? trimmed : 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<DirectApnsRegistration> & { 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<RelayApnsRegistration> & {
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<string, unknown>;
const transport =
typeof candidate.transport === "string" ? candidate.transport.trim().toLowerCase() : "direct";
if (transport === "relay") {
return normalizeRelayRegistration(candidate as Partial<RelayApnsRegistration>);
}
return normalizeDirectRegistration(candidate as Partial<DirectApnsRegistration>);
}
async function loadRegistrationsState(baseDir?: string): Promise<ApnsRegistrationState> { async function loadRegistrationsState(baseDir?: string): Promise<ApnsRegistrationState> {
const filePath = resolveApnsRegistrationPath(baseDir); const filePath = resolveApnsRegistrationPath(baseDir);
const existing = await readJsonFile<ApnsRegistrationState>(filePath); const existing = await readJsonFile<ApnsRegistrationState>(filePath);
@ -173,7 +365,16 @@ async function loadRegistrationsState(baseDir?: string): Promise<ApnsRegistratio
!Array.isArray(existing.registrationsByNodeId) !Array.isArray(existing.registrationsByNodeId)
? existing.registrationsByNodeId ? existing.registrationsByNodeId
: {}; : {};
return { registrationsByNodeId: registrations }; const normalized: Record<string, ApnsRegistration> = {};
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( async function persistRegistrationsState(
@ -181,7 +382,11 @@ async function persistRegistrationsState(
baseDir?: string, baseDir?: string,
): Promise<void> { ): Promise<void> {
const filePath = resolveApnsRegistrationPath(baseDir); 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 { export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null {
@ -195,41 +400,90 @@ export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null
return null; return null;
} }
export async function registerApnsRegistration(
params: RegisterApnsParams,
): Promise<ApnsRegistration> {
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: { export async function registerApnsToken(params: {
nodeId: string; nodeId: string;
token: string; token: string;
topic: string; topic: string;
environment?: unknown; environment?: unknown;
baseDir?: string; baseDir?: string;
}): Promise<ApnsRegistration> { }): Promise<DirectApnsRegistration> {
const nodeId = normalizeNodeId(params.nodeId); return (await registerApnsRegistration({
const token = normalizeApnsToken(params.token); ...params,
const topic = normalizeTopic(params.topic); transport: "direct",
const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox"; })) as DirectApnsRegistration;
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;
});
} }
export async function loadApnsRegistration( export async function loadApnsRegistration(
@ -244,6 +498,95 @@ export async function loadApnsRegistration(
return state.registrationsByNodeId[normalizedNodeId] ?? null; return state.registrationsByNodeId[normalizedNodeId] ?? null;
} }
export async function clearApnsRegistration(nodeId: string, baseDir?: string): Promise<boolean> {
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<boolean> {
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( export async function resolveApnsAuthConfigFromEnv(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): Promise<ApnsAuthConfigResolution> { ): Promise<ApnsAuthConfigResolution> {
@ -386,7 +729,10 @@ function resolveApnsTimeoutMs(timeoutMs: number | undefined): number {
: DEFAULT_APNS_TIMEOUT_MS; : DEFAULT_APNS_TIMEOUT_MS;
} }
function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: ApnsRegistration }): { function resolveDirectSendContext(params: {
auth: ApnsAuthConfig;
registration: DirectApnsRegistration;
}): {
token: string; token: string;
topic: string; topic: string;
environment: ApnsEnvironment; environment: ApnsEnvironment;
@ -397,7 +743,7 @@ function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: Ap
throw new Error("invalid APNs token"); throw new Error("invalid APNs token");
} }
const topic = normalizeTopic(params.registration.topic); const topic = normalizeTopic(params.registration.topic);
if (!topic) { if (!isValidTopic(topic)) {
throw new Error("topic required"); throw new Error("topic required");
} }
return { return {
@ -408,24 +754,7 @@ function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: Ap
}; };
} }
function toApnsPushResult(params: { function toPushMetadata(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: {
kind: "push.test" | "node.wake"; kind: "push.test" | "node.wake";
nodeId: string; nodeId: string;
reason?: string; reason?: string;
@ -438,16 +767,61 @@ function createOpenClawPushMetadata(params: {
}; };
} }
async function sendApnsPush(params: { function resolveRegistrationDebugSuffix(
auth: ApnsAuthConfig; registration: ApnsRegistration,
relayResult?: Pick<ApnsRelayPushResponse, "tokenSuffix">,
): string {
if (registration.transport === "direct") {
return registration.token.slice(-8);
}
return (
relayResult?.tokenSuffix ?? registration.tokenDebugSuffix ?? registration.relayHandle.slice(-8)
);
}
function toPushResult(params: {
registration: ApnsRegistration; 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; payload: object;
timeoutMs?: number; timeoutMs?: number;
requestSender?: ApnsRequestSender; requestSender?: ApnsRequestSender;
pushType: ApnsPushType; pushType: ApnsPushType;
priority: "10" | "5"; priority: "10" | "5";
}): Promise<ApnsPushWakeResult> { }): Promise<ApnsPushResult> {
const { token, topic, environment, bearerToken } = resolveApnsSendContext({ const { token, topic, environment, bearerToken } = resolveDirectSendContext({
auth: params.auth, auth: params.auth,
registration: params.registration, registration: params.registration,
}); });
@ -462,19 +836,37 @@ async function sendApnsPush(params: {
pushType: params.pushType, pushType: params.pushType,
priority: params.priority, priority: params.priority,
}); });
return toApnsPushResult({ response, token, topic, environment }); return toPushResult({
registration: params.registration,
response,
tokenSuffix: token.slice(-8),
});
} }
export async function sendApnsAlert(params: { async function sendRelayApnsPush(params: {
auth: ApnsAuthConfig; relayConfig: ApnsRelayConfig;
registration: ApnsRegistration; registration: RelayApnsRegistration;
nodeId: string; payload: object;
title: string; pushType: ApnsPushType;
body: string; priority: "10" | "5";
timeoutMs?: number; gatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
requestSender?: ApnsRequestSender; requestSender?: ApnsRelayRequestSender;
}): Promise<ApnsPushAlertResult> { }): Promise<ApnsPushResult> {
const payload = { 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: { aps: {
alert: { alert: {
title: params.title, title: params.title,
@ -482,48 +874,136 @@ export async function sendApnsAlert(params: {
}, },
sound: "default", sound: "default",
}, },
openclaw: createOpenClawPushMetadata({ openclaw: toPushMetadata({
kind: "push.test", kind: "push.test",
nodeId: params.nodeId, 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: { function createBackgroundPayload(params: { nodeId: string; wakeReason?: string }): object {
auth: ApnsAuthConfig; return {
registration: ApnsRegistration;
nodeId: string;
wakeReason?: string;
timeoutMs?: number;
requestSender?: ApnsRequestSender;
}): Promise<ApnsPushWakeResult> {
const payload = {
aps: { aps: {
"content-available": 1, "content-available": 1,
}, },
openclaw: createOpenClawPushMetadata({ openclaw: toPushMetadata({
kind: "node.wake", kind: "node.wake",
reason: params.wakeReason ?? "node.invoke", reason: params.wakeReason ?? "node.invoke",
nodeId: params.nodeId, 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<DeviceIdentity, "deviceId" | "privateKeyPem">;
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<DeviceIdentity, "deviceId" | "privateKeyPem">;
auth?: never;
requestSender?: never;
};
export async function sendApnsAlert(
params: DirectApnsAlertParams | RelayApnsAlertParams,
): Promise<ApnsPushAlertResult> {
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, payload,
timeoutMs: params.timeoutMs, timeoutMs: directParams.timeoutMs,
requestSender: params.requestSender, requestSender: directParams.requestSender,
pushType: "alert",
priority: "10",
});
}
export async function sendApnsBackgroundWake(
params: DirectApnsBackgroundWakeParams | RelayApnsBackgroundWakeParams,
): Promise<ApnsPushWakeResult> {
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", pushType: "background",
priority: "5", priority: "5",
}); });
} }
export { type ApnsRelayConfig, type ApnsRelayConfigResolution, resolveApnsRelayConfigFromEnv };