mirror of https://github.com/openclaw/openclaw.git
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:
parent
9342739d71
commit
b77b7485e0
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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] {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue