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