Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import Foundation
import UIKit
enum BackgroundAliveBeacon {
static let eventName = "node.presence.alive"
static let minSuccessIntervalSeconds: TimeInterval = 10 * 60
enum Trigger: String, CaseIterable, Codable {
case background
case silentPush = "silent_push"
case bgAppRefresh = "bg_app_refresh"
case significantLocation = "significant_location"
case manual
case connect
}
struct Payload: Encodable {
var trigger: String
var sentAtMs: Int64
var displayName: String
var version: String
var platform: String
var deviceFamily: String
var modelIdentifier: String
var pushTransport: String?
}
struct NodeEventRequestPayload: Codable {
var event: String = BackgroundAliveBeacon.eventName
var payloadJSON: String
}
struct NodeEventResponsePayload: Decodable {
var ok: Bool?
var event: String?
var handled: Bool?
var reason: String?
}
static func normalizeTrigger(_ raw: String) -> Trigger {
let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return Trigger(rawValue: normalized) ?? .background
}
static func shouldSkipRecentSuccess(
isGatewayConnected: Bool,
now: Date,
lastSuccessAtMs: Double?,
minInterval: TimeInterval = Self.minSuccessIntervalSeconds) -> Bool
{
guard isGatewayConnected else { return false }
guard let lastSuccessAtMs, lastSuccessAtMs > 0 else { return false }
let elapsed = now.timeIntervalSince1970 - (lastSuccessAtMs / 1000.0)
return elapsed >= 0 && elapsed < minInterval
}
@MainActor
static func makePayload(trigger: Trigger, displayName: String, pushTransport: String?) -> Payload {
Payload(
trigger: trigger.rawValue,
sentAtMs: Int64(Date().timeIntervalSince1970 * 1000),
displayName: displayName,
version: DeviceInfoHelper.appVersion(),
platform: DeviceInfoHelper.platformString(),
deviceFamily: DeviceInfoHelper.deviceFamily(),
modelIdentifier: DeviceInfoHelper.modelIdentifier(),
pushTransport: pushTransport)
}
static func makeNodeEventRequestPayloadJSON(
payload: Payload,
encoder: JSONEncoder = JSONEncoder()) throws -> String
{
let payloadData = try encoder.encode(payload)
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
throw EncodingError.invalidValue(payload, EncodingError.Context(
codingPath: [],
debugDescription: "Failed to encode background alive payload as UTF-8"))
}
let requestData = try encoder.encode(NodeEventRequestPayload(payloadJSON: payloadJSON))
guard let requestJSON = String(data: requestData, encoding: .utf8) else {
throw EncodingError.invalidValue(payload, EncodingError.Context(
codingPath: [],
debugDescription: "Failed to encode node.event payload as UTF-8"))
}
return requestJSON
}
static func decodeResponse(_ data: Data) -> NodeEventResponsePayload? {
try? JSONDecoder().decode(NodeEventResponsePayload.self, from: data)
}
}

View File

@@ -0,0 +1,115 @@
import Foundation
@preconcurrency import UserNotifications
struct ExecApprovalNotificationPrompt: Equatable {
let approvalId: String
}
enum ExecApprovalNotificationBridge {
static let requestedKind = "exec.approval.requested"
static let resolvedKind = "exec.approval.resolved"
static let categoryIdentifier = "openclaw.exec-approval"
static let reviewActionIdentifier = "openclaw.exec-approval.review"
private static let localRequestPrefix = "exec.approval."
static func registerCategory(center: UNUserNotificationCenter = .current()) {
let category = UNNotificationCategory(
identifier: self.categoryIdentifier,
actions: [
UNNotificationAction(
identifier: self.reviewActionIdentifier,
title: "Review",
options: [.foreground]),
],
intentIdentifiers: [],
options: [])
center.getNotificationCategories { categories in
var updated = categories
updated.update(with: category)
center.setNotificationCategories(updated)
}
}
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
self.payloadKind(userInfo: userInfo) == self.requestedKind
}
static func parsePrompt(
actionIdentifier: String,
userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt?
{
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|| actionIdentifier == self.reviewActionIdentifier
else {
return nil
}
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
return ExecApprovalNotificationPrompt(approvalId: approvalId)
}
@MainActor
static func handleResolvedPushIfNeeded(
userInfo: [AnyHashable: Any],
notificationCenter: NotificationCentering) async -> Bool
{
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
let approvalId = self.approvalID(from: userInfo)
else {
return false
}
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
return true
}
@MainActor
static func removeNotifications(
forApprovalID approvalId: String,
notificationCenter: NotificationCentering) async
{
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
await notificationCenter.removePendingNotificationRequests(
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
let delivered = await notificationCenter.deliveredNotifications()
let identifiers = delivered.compactMap { snapshot -> String? in
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
return snapshot.identifier
}
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
}
static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func localRequestIdentifier(for approvalId: String) -> String {
"\(self.localRequestPrefix)\(approvalId)"
}
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
private static func openClawPayload(userInfo: [AnyHashable: Any]) -> [String: Any]? {
if let payload = userInfo["openclaw"] as? [String: Any] {
return payload
}
if let payload = userInfo["openclaw"] as? [AnyHashable: Any] {
return payload.reduce(into: [String: Any]()) { partialResult, pair in
guard let key = pair.key as? String else { return }
partialResult[key] = pair.value
}
}
return nil
}
}

View File

@@ -0,0 +1,149 @@
import Foundation
enum PushTransportMode: String {
case direct
case relay
}
enum PushDistributionMode: String {
case local
case official
}
enum PushAPNsEnvironment: String {
case sandbox
case production
}
enum PushRelayProfile: String {
case production
case deviceSandbox
case simulatorSandbox
}
enum PushProofPolicy: String {
case appleStrict
case appleDevelopment
case internalSimulator
}
enum PushBuildMode: String {
case localSandbox
case localProduction
case appStore
case deviceSandbox
case simulatorSandbox
}
struct PushBuildConfig {
let mode: PushBuildMode
let transport: PushTransportMode
let distribution: PushDistributionMode
let relayBaseURL: URL?
let apnsEnvironment: PushAPNsEnvironment
let relayProfile: PushRelayProfile
let proofPolicy: PushProofPolicy
static let current = PushBuildConfig()
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
static let openClawSandboxRelayHost = "ios-push-relay-sandbox.openclaw.ai"
var usesOpenClawHostedRelay: Bool {
guard self.transport == .relay, self.distribution == .official else { return false }
guard let relayBaseURL = self.relayBaseURL,
let components = URLComponents(url: relayBaseURL, resolvingAgainstBaseURL: false)
else {
return false
}
return components.scheme?.lowercased() == "https"
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
.contains(components.host?.lowercased() ?? "")
&& components.user == nil
&& components.password == nil
}
init(bundle: Bundle = .main) {
self.init(readValue: { bundle.object(forInfoDictionaryKey: $0) })
}
init(infoDictionary: [String: Any]) {
self.init(readValue: { infoDictionary[$0] })
}
private init(readValue: (String) -> Any?) {
self.mode = Self.readEnum(
readValue: readValue,
key: "OpenClawPushMode",
fallback: .localSandbox)
let relayBaseURLOverride = Self.readURL(
readValue: readValue,
key: "OpenClawPushRelayBaseURL")
switch self.mode {
case .localSandbox:
self.transport = .direct
self.distribution = .local
self.relayBaseURL = nil
self.apnsEnvironment = .sandbox
self.relayProfile = .deviceSandbox
self.proofPolicy = .appleDevelopment
case .localProduction:
self.transport = .direct
self.distribution = .local
self.relayBaseURL = nil
self.apnsEnvironment = .production
self.relayProfile = .production
self.proofPolicy = .appleStrict
case .appStore:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = URL(string: "https://\(Self.openClawHostedRelayHost)")!
self.apnsEnvironment = .production
self.relayProfile = .production
self.proofPolicy = .appleStrict
case .deviceSandbox:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = relayBaseURLOverride
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
self.apnsEnvironment = .sandbox
self.relayProfile = .deviceSandbox
self.proofPolicy = .appleDevelopment
case .simulatorSandbox:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = relayBaseURLOverride
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
self.apnsEnvironment = .sandbox
self.relayProfile = .simulatorSandbox
self.proofPolicy = .internalSimulator
}
}
private static func readURL(readValue: (String) -> Any?, key: String) -> URL? {
guard let raw = readValue(key) as? String else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let components = URLComponents(string: trimmed),
components.scheme?.lowercased() == "https",
let host = components.host,
!host.isEmpty,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil
else {
return nil
}
return components.url
}
private static func readEnum<T: RawRepresentable>(
readValue: (String) -> Any?,
key: String,
fallback: T)
-> T where T.RawValue == String {
guard let raw = readValue(key) as? String else { return fallback }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
enum PushEnrollmentConsent {
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
static var disclosureAccepted: Bool {
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
}
static func markDisclosureAccepted() {
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
}
#if DEBUG
static func reset() {
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
}
#endif
}

View File

@@ -0,0 +1,211 @@
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 relayOrigin: 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 an official push build mode")
}
try Self.validateRelayContract(
relayProfile: self.buildConfig.relayProfile,
apnsEnvironment: self.buildConfig.apnsEnvironment,
proofPolicy: self.buildConfig.proofPolicy)
GatewayDiagnostics.pushRelay.stage(
"contract validated apns=\(self.buildConfig.apnsEnvironment.rawValue) "
+ "profile=\(self.buildConfig.relayProfile.rawValue) "
+ "proof=\(self.buildConfig.proofPolicy.rawValue)")
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.apnsEnvironment == self.buildConfig.apnsEnvironment.rawValue,
stored.relayProfile == self.buildConfig.relayProfile.rawValue,
stored.proofPolicy == self.buildConfig.proofPolicy.rawValue,
stored.lastAPNsTokenHashHex == tokenHashHex,
!Self.isExpired(stored.relayHandleExpiresAtMs)
{
GatewayDiagnostics.pushRelay.stage("using cached relay registration")
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,
relayOrigin: relayOrigin,
tokenDebugSuffix: stored.tokenDebugSuffix))
}
GatewayDiagnostics.pushRelay.stage("relay registration cache miss")
let response = try await relayClient.register(PushRelayRegistrationInput(
installationId: installationId,
bundleId: bundleId,
appVersion: DeviceInfoHelper.appVersion(),
environment: self.buildConfig.apnsEnvironment,
relayProfile: self.buildConfig.relayProfile,
proofPolicy: self.buildConfig.proofPolicy,
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,
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
relayProfile: self.buildConfig.relayProfile.rawValue,
proofPolicy: self.buildConfig.proofPolicy.rawValue)
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
GatewayDiagnostics.pushRelay.stage("stored relay registration hasExpiry=\(response.expiresAtMs != nil)")
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,
relayOrigin: relayOrigin,
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 + 60000
}
private static func validateRelayContract(
relayProfile: PushRelayProfile,
apnsEnvironment: PushAPNsEnvironment,
proofPolicy: PushProofPolicy)
throws {
switch relayProfile {
case .production:
guard apnsEnvironment == .production, proofPolicy == .appleStrict else {
throw PushRelayError.relayMisconfigured(
"production relay profile requires production APNs and appleStrict proof")
}
case .deviceSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .appleDevelopment else {
throw PushRelayError.relayMisconfigured(
"deviceSandbox relay profile requires sandbox APNs and appleDevelopment proof")
}
case .simulatorSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .internalSimulator else {
throw PushRelayError.relayMisconfigured(
"simulatorSandbox relay profile requires sandbox APNs and internalSimulator proof")
}
}
}
private static func sha256Hex(_ value: String) -> String {
let digest = SHA256.hash(data: Data(value.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func normalizeTokenSuffix(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private static func encodePayload(_ payload: some Encodable) throws -> String {
let data = try JSONEncoder().encode(payload)
guard let json = String(data: data, encoding: .utf8) else {
throw PushRelayError.relayMisconfigured("Failed to encode push registration payload as UTF-8")
}
return json
}
}

View File

@@ -0,0 +1,492 @@
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 app transaction 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 relayProfile: String
var apnsEnvironment: String
var proofPolicy: 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 relayProfile: String
var apnsEnvironment: String
var proofPolicy: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
var apnsToken: String
var appAttest: PushRelayAppAttestPayload?
var receipt: PushRelayReceiptPayload?
var simulatorProof: PushRelaySimulatorProofPayload?
}
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 struct PushRelayAppAttestProof {
var keyId: String
var attestationObject: String?
var assertion: String
var clientDataHash: String
var signedPayloadBase64: String
}
private struct PushRelaySimulatorProofPayload: Encodable {
var signedPayloadBase64: String
var hmacSha256Base64Url: String
}
private final class PushRelayAppAttestService {
func createProof(
challenge: String,
signedPayload: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof {
let service = DCAppAttestService.shared
guard service.isSupported else {
throw PushRelayError.unsupportedAppAttest
}
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
let attestationObject = try await self.attestKeyIfNeeded(
service: service,
keyID: keyID,
challenge: challenge,
scope: scope)
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
let assertion = try await self.generateAssertion(
service: service,
keyID: keyID,
signedPayloadHash: signedPayloadHash,
scope: scope)
return PushRelayAppAttestProof(
keyId: keyID,
attestationObject: attestationObject,
assertion: assertion.base64EncodedString(),
clientDataHash: Self.base64URL(signedPayloadHash),
signedPayloadBase64: signedPayload.base64EncodedString())
}
private func loadOrCreateKeyID(
using service: DCAppAttestService,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> String {
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
!existing.isEmpty
{
return existing
}
let keyID = try await service.generateKey()
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
return keyID
}
private func attestKeyIfNeeded(
service: DCAppAttestService,
keyID: String,
challenge: String,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> String? {
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == 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, scope: scope)
return attestation.base64EncodedString()
}
private func generateAssertion(
service: DCAppAttestService,
keyID: String,
signedPayloadHash: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> Data {
do {
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
} catch {
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
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 {
do {
let result = try await AppTransaction.shared
return try Self.appTransactionBase64(result)
} catch {
let refreshed = try await AppTransaction.refresh()
return try Self.appTransactionBase64(refreshed)
}
}
private static func appTransactionBase64(
_ result: StoreKit.VerificationResult<AppTransaction>) throws -> String
{
let jws = result.jwsRepresentation.trimmingCharacters(in: .whitespacesAndNewlines)
guard !jws.isEmpty else {
throw PushRelayError.missingReceipt
}
return Data(jws.utf8).base64EncodedString()
}
}
private final class PushRelaySimulatorProofProvider {
func createProof(signedPayload: Data) throws -> PushRelaySimulatorProofPayload {
#if targetEnvironment(simulator)
guard let secret = ProcessInfo.processInfo.environment["OPENCLAW_SIMULATOR_PUSH_PROOF_SECRET"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!secret.isEmpty
else {
throw PushRelayError.relayMisconfigured("Simulator push proof secret missing")
}
let signedPayloadBase64 = signedPayload.base64EncodedString()
let signature = HMAC<SHA256>.authenticationCode(
for: Data(signedPayloadBase64.utf8),
using: SymmetricKey(data: Data(secret.utf8)))
return PushRelaySimulatorProofPayload(
signedPayloadBase64: signedPayloadBase64,
hmacSha256Base64Url: Self.base64URL(Data(signature)))
#else
throw PushRelayError.relayMisconfigured("Simulator proof is only available in iOS Simulator")
#endif
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
struct PushRelayRegistrationInput {
var installationId: String
var bundleId: String
var appVersion: String
var environment: PushAPNsEnvironment
var relayProfile: PushRelayProfile
var proofPolicy: PushProofPolicy
var distribution: PushDistributionMode
var apnsTokenHex: String
var gatewayIdentity: PushRelayGatewayIdentity
}
/// 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()
private let simulatorProofProvider = PushRelaySimulatorProofProvider()
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
}
var normalizedBaseURLString: String {
Self.normalizeBaseURLString(self.baseURL)
}
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
GatewayDiagnostics.pushRelay.stage(
"registration start origin=\(self.normalizedBaseURLString) "
+ "apns=\(input.environment.rawValue) "
+ "profile=\(input.relayProfile.rawValue) "
+ "proof=\(input.proofPolicy.rawValue)")
let challenge: PushRelayChallengeResponse
do {
GatewayDiagnostics.pushRelay.stage("challenge request start")
challenge = try await self.fetchChallenge()
GatewayDiagnostics.pushRelay.stage("challenge received")
} catch {
GatewayDiagnostics.pushRelay.failed("challenge request", error: error)
throw error
}
let signedPayload = PushRelayRegisterSignedPayload(
challengeId: challenge.challengeId,
installationId: input.installationId,
bundleId: input.bundleId,
environment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
apnsEnvironment: input.environment.rawValue,
proofPolicy: input.proofPolicy.rawValue,
distribution: input.distribution.rawValue,
gateway: input.gatewayIdentity,
appVersion: input.appVersion,
apnsToken: input.apnsTokenHex)
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
let appAttestScope = PushRelayRegistrationStore.AppAttestScope(
relayOrigin: self.normalizedBaseURLString,
apnsEnvironment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
proofPolicy: input.proofPolicy.rawValue)
let appAttest: PushRelayAppAttestProof?
do {
GatewayDiagnostics.pushRelay.stage("app attest proof start")
appAttest = try await self.createAppAttestProofIfNeeded(
proofPolicy: input.proofPolicy,
challenge: challenge.challenge,
signedPayloadData: signedPayloadData,
scope: appAttestScope)
GatewayDiagnostics.pushRelay.stage("app attest proof complete included=\(appAttest != nil)")
} catch {
GatewayDiagnostics.pushRelay.failed("app attest proof", error: error)
throw error
}
let receipt: PushRelayReceiptPayload?
do {
GatewayDiagnostics.pushRelay.stage("receipt proof start")
receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
GatewayDiagnostics.pushRelay.stage("receipt proof complete included=\(receipt != nil)")
} catch {
GatewayDiagnostics.pushRelay.failed("receipt proof", error: error)
throw error
}
let simulatorProof: PushRelaySimulatorProofPayload?
do {
simulatorProof = try self.createSimulatorProofIfNeeded(
proofPolicy: input.proofPolicy,
signedPayloadData: signedPayloadData)
GatewayDiagnostics.pushRelay.stage("simulator proof complete included=\(simulatorProof != nil)")
} catch {
GatewayDiagnostics.pushRelay.failed("simulator proof", error: error)
throw error
}
let requestBody = PushRelayRegisterRequest(
challengeId: signedPayload.challengeId,
installationId: signedPayload.installationId,
bundleId: signedPayload.bundleId,
environment: signedPayload.environment,
relayProfile: signedPayload.relayProfile,
apnsEnvironment: signedPayload.apnsEnvironment,
proofPolicy: signedPayload.proofPolicy,
distribution: signedPayload.distribution,
gateway: signedPayload.gateway,
appVersion: signedPayload.appVersion,
apnsToken: signedPayload.apnsToken,
appAttest: appAttest.map {
PushRelayAppAttestPayload(
keyId: $0.keyId,
attestationObject: $0.attestationObject,
assertion: $0.assertion,
clientDataHash: $0.clientDataHash,
signedPayloadBase64: $0.signedPayloadBase64)
},
receipt: receipt,
simulatorProof: simulatorProof)
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: Data
let response: URLResponse
do {
GatewayDiagnostics.pushRelay.stage("register request start")
(data, response) = try await self.session.data(for: request)
} catch {
GatewayDiagnostics.pushRelay.failed("register request", error: error)
throw error
}
let status = Self.statusCode(from: response)
GatewayDiagnostics.pushRelay.stage("register response status=\(status)")
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(scope: appAttestScope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
}
let relayError = PushRelayError.requestFailed(
status: status,
message: Self.decodeErrorMessage(data: data))
GatewayDiagnostics.pushRelay.stage("register response failed status=\(status)")
throw relayError
}
do {
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
GatewayDiagnostics.pushRelay.stage("registration response decoded")
return decoded
} catch {
GatewayDiagnostics.pushRelay.failed("registration response decode", error: error)
throw error
}
}
private func createAppAttestProofIfNeeded(
proofPolicy: PushProofPolicy,
challenge: String,
signedPayloadData: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof? {
guard proofPolicy != .internalSimulator else { return nil }
return try await self.appAttest.createProof(
challenge: challenge,
signedPayload: signedPayloadData,
scope: scope)
}
private func createReceiptIfNeeded(
proofPolicy: PushProofPolicy)
async throws -> PushRelayReceiptPayload? {
switch proofPolicy {
case .appleStrict:
return try await PushRelayReceiptPayload(base64: self.receiptProvider.loadReceiptBase64())
case .appleDevelopment:
guard let receiptBase64 = try? await self.receiptProvider.loadReceiptBase64() else {
return nil
}
return PushRelayReceiptPayload(base64: receiptBase64)
case .internalSimulator:
return nil
}
}
private func createSimulatorProofIfNeeded(
proofPolicy: PushProofPolicy,
signedPayloadData: Data)
throws -> PushRelaySimulatorProofPayload? {
guard proofPolicy == .internalSimulator else { return nil }
return try self.simulatorProofProvider.createProof(signedPayload: signedPayloadData)
}
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
let endpoint = self.baseURL.appending(path: "v1/push/challenge")
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.timeoutInterval = 10
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = Data("{}".utf8)
let (data, response) = try await self.session.data(for: request)
let status = Self.statusCode(from: response)
guard (200..<300).contains(status) else {
throw PushRelayError.requestFailed(
status: status,
message: Self.decodeErrorMessage(data: data))
}
return try self.decode(PushRelayChallengeResponse.self, from: data)
}
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
do {
return try self.jsonDecoder.decode(type, from: data)
} catch {
throw PushRelayError.invalidResponse(error.localizedDescription)
}
}
private static func statusCode(from response: URLResponse) -> Int {
(response as? HTTPURLResponse)?.statusCode ?? 0
}
private static func normalizeBaseURLString(_ url: URL) -> String {
var absolute = url.absoluteString
while absolute.hasSuffix("/") {
absolute.removeLast()
}
return absolute
}
private static func decodeErrorMessage(data: Data) -> String {
if let decoded = try? JSONDecoder().decode(RelayErrorResponse.self, from: data) {
let message = decoded.message ?? decoded.reason ?? decoded.error ?? ""
if !message.isEmpty {
return message
}
}
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? "unknown relay error" : raw
}
}

View File

@@ -0,0 +1,156 @@
import CryptoKit
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
var apnsEnvironment: String?
var relayProfile: String?
var proofPolicy: String?
}
enum PushRelayRegistrationStore {
private static let service = "ai.openclawfoundation.app.pushrelay"
private static let registrationStateAccount = "registration-state"
private static let appAttestKeyIDAccount = "app-attest-key-id"
private static let appAttestedKeyIDAccount = "app-attested-key-id"
struct AppAttestScope {
var relayOrigin: String
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: String
}
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
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: 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,
apnsEnvironment: decoded.apnsEnvironment ?? "production",
relayProfile: decoded.relayProfile ?? "production",
proofPolicy: decoded.proofPolicy ?? "appleStrict")
}
@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,
apnsEnvironment: state.apnsEnvironment,
relayProfile: state.relayProfile,
proofPolicy: state.proofPolicy)
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)
}
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
}
@discardableResult
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
}
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
}
@discardableResult
static func clearAttestedKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
}
private static func scopedAccount(_ baseAccount: String, scope: AppAttestScope) -> String {
let raw = [
scope.relayOrigin,
scope.apnsEnvironment,
scope.relayProfile,
scope.proofPolicy,
].joined(separator: "\n")
let digest = SHA256.hash(data: Data(raw.utf8))
.map { String(format: "%02x", $0) }
.joined()
// A relay sees an App Attest key as attested only after receiving that
// key's attestation object, so keep key state isolated per relay context.
return "\(baseAccount)-\(digest)"
}
}