fix(ios): defer QR pairing after scanner dismissal (#99572)

* fix(ios): defer QR pairing after scanner dismissal

* fix(ios): process QR pairing after scanner dismissal

* fix(ios): harden QR scanner handoff

* fix(ios): give QR scanner dismissal more time

* fix(ios): keep onboarding open for QR trust prompt

* fix(ios): keep QR trust prompt owned by onboarding

* fix(ios): recover operator pairing after QR bootstrap

* fix(ios): cancel stale QR scanner handoffs

Co-authored-by: PollyBot13 <pollybot13@gmail.com>

* fix(ios): defer QR setup until onboarding closes

* fix(ios): keep QR setup links with visible settings

* fix(ios): consume setup links during onboarding

* fix(ios): handle setup links during onboarding launch

* fix(ios): route setup links through active onboarding

* fix(ios): harden QR gateway handoff

* fix(ios): cancel superseded gateway attempts

* fix(ios): serialize scanner result delivery

* fix(ios): prevent stale gateway reconnects

* fix(ios): serialize gateway target handoff

* fix(ios): disable stale gateway relaunch route

* fix(ios): await staged bootstrap reset

* test(ios): bound gateway reset handoff

* fix(ios): preserve explicit gateway handoff

* fix(ios): harden gateway lifecycle ownership

* chore(ios): sync native i18n inventory

* test(ios): align gateway ownership assertions

* refactor(ios): remove superseded gateway helpers

* fix(ios): keep gateway auth route scoped

* fix(ios): restore gateway target review state

* fix(protocol): refresh Swift plugin approval model

* test(ios): isolate state directory overrides

* fix(ios): preserve watch alerts across gateway switches

* fix(ios): bind deferred work to gateway ownership

* docs(changelog): credit iOS gateway handoff fix

* chore(i18n): sync native app inventory

* test(ios): remove unused Watch approval hooks

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
PollyBot13
2026-07-05 21:31:53 +02:00
committed by GitHub
parent a4499d698d
commit a320f775f0
65 changed files with 9360 additions and 1748 deletions
+1
View File
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **iOS QR gateway handoff:** stop VisionKit before delivering scanned setup codes, and keep deferred auth, approval, Watch, and foreground-node work bound to its originating gateway across reconnects. (#99572) Thanks @PollyBot13.
- **iOS Voice Wake cleanup:** avoid initializing the microphone audio pipeline while disabling inactive Voice Wake, preventing simulator launch aborts and unnecessary audio setup.
- **Cron duration validation:** reject positive durations that truncate below one millisecond instead of silently scheduling a zero-duration interval. (#100311) Thanks @qingminglong.
- **Skill workshop proposals:** preserve the terminal newline in generated proposal Markdown while still rejecting blank raw content. (#100293) Thanks @anyech.
File diff suppressed because it is too large Load Diff
@@ -164,7 +164,7 @@ final class ShareViewController: UIViewController {
}
private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws {
guard let config = ShareGatewayRelaySettings.loadConfig() else {
guard let config = ShareGatewayRelaySettings.loadConfigDiscardingUnscopedDeviceAuth() else {
throw NSError(
domain: "OpenClawShare",
code: 10,
@@ -200,7 +200,9 @@ final class ShareViewController: UIViewController {
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
includeDeviceIdentity: true,
allowStoredDeviceAuth: config.gatewayStableID != nil,
deviceAuthGatewayID: config.gatewayStableID)
}
do {
@@ -214,8 +214,19 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
try await self.requestHistory(sessionKey: sessionKey, ifCurrentRoute: nil)
}
func requestHistory(
sessionKey: String,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatHistoryPayload
{
let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey)
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
let res = try await self.gateway.request(
method: "chat.history",
paramsJSON: json,
timeoutSeconds: 15,
ifCurrentRoute: expectedRoute)
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
}
@@ -236,6 +247,23 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
try await self.sendMessage(
sessionKey: sessionKey,
message: message,
thinking: thinking,
idempotencyKey: idempotencyKey,
attachments: attachments,
ifCurrentRoute: nil)
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload],
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async throws -> OpenClawChatSendResponse
{
let startLogMessage =
"chat.send start sessionKey=\(sessionKey) "
@@ -250,7 +278,11 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
idempotencyKey: idempotencyKey,
attachments: attachments)
do {
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
let res = try await self.gateway.request(
method: "chat.send",
paramsJSON: json,
timeoutSeconds: 35,
ifCurrentRoute: expectedRoute)
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
@@ -294,6 +326,17 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
func waitForRunCompletion(runId rawRunId: String, timeoutMs: Int) async -> Bool {
await self.waitForRunCompletion(
runId: rawRunId,
timeoutMs: timeoutMs,
ifCurrentRoute: nil)
}
func waitForRunCompletion(
runId rawRunId: String,
timeoutMs: Int,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute?) async -> Bool
{
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !runId.isEmpty else { return false }
@@ -304,7 +347,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
let res = try await self.gateway.request(
method: "agent.wait",
paramsJSON: json,
timeoutSeconds: requestTimeoutSeconds)
timeoutSeconds: requestTimeoutSeconds,
ifCurrentRoute: expectedRoute)
let completion = try Self.decodeAgentWaitCompletion(res, fallbackRunId: runId)
GatewayDiagnostics.log("agent.wait completed runId=\(completion.runId) status=\(completion.status)")
if !completion.completed {
+56 -40
View File
@@ -44,11 +44,15 @@ struct SettingsProTab: View {
@State var selectedAgentPickerId = ""
@State var gatewayToken = ""
@State var gatewayPassword = ""
@State var gatewayCredentialFieldStableID: String?
@State var manualGatewayPortText = ""
@State var setupStatusText: String?
@State var setupAttemptID: UUID?
@State var stagedGatewaySetupLink: GatewayConnectDeepLink?
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State var scannerResultHandoff = QRScannerResultHandoff()
@State var scannerScanID: UInt64 = 0
@State var pendingTargetSuppression = GatewayPendingTargetSuppression()
@State var defaultShareInstruction = ""
@State var showQRScanner = false
@State var scannerError: String?
@@ -71,6 +75,7 @@ struct SettingsProTab: View {
@State private var navigationPath: [SettingsRoute] = []
let initialRoute: SettingsRoute?
let directRoute: SettingsRoute?
let acceptsGatewaySetupRequests: Bool
let headerLeadingAction: OpenClawSidebarHeaderAction?
let ownsNavigationStack: Bool
let navigateToRoute: ((SettingsRoute) -> Void)?
@@ -81,6 +86,7 @@ struct SettingsProTab: View {
init(
initialRoute: SettingsRoute? = nil,
directRoute: SettingsRoute? = nil,
acceptsGatewaySetupRequests: Bool = false,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
ownsNavigationStack: Bool = true,
navigateToRoute: ((SettingsRoute) -> Void)? = nil,
@@ -90,6 +96,7 @@ struct SettingsProTab: View {
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.acceptsGatewaySetupRequests = acceptsGatewaySetupRequests
self.headerLeadingAction = headerLeadingAction
self.ownsNavigationStack = ownsNavigationStack
self.navigateToRoute = navigateToRoute
@@ -155,6 +162,10 @@ struct SettingsProTab: View {
self.applyInitialRouteIfNeeded()
self.notifyRouteChange()
}
.onDisappear {
self.scannerResultHandoff.cancel()
self.pendingTargetSuppression.resumeAutoConnect(controller: self.gatewayController)
}
.onChange(of: self.gatewaySetupRequest?.id) { _, _ in
self.applyGatewaySetupRequestIfNeeded()
}
@@ -176,20 +187,18 @@ struct SettingsProTab: View {
self.selectedAgentPickerId = newValue
}
}
.onChange(of: self.gatewayToken) { _, newValue in
self.persistGatewayToken(newValue)
}
.onChange(of: self.gatewayPassword) { _, newValue in
self.persistGatewayPassword(newValue)
}
.onChange(of: self.setupCode) { _, newValue in
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.stagedGatewaySetupLink = nil
self.clearStagedGatewaySetupLink()
}
}
.onChange(of: self.defaultShareInstruction) { _, newValue in
ShareToAgentSettings.saveDefaultInstruction(newValue)
}
.onChange(of: self.acceptsGatewaySetupRequests) { _, acceptsRequests in
guard acceptsRequests else { return }
self.applyGatewaySetupRequestIfNeeded()
}
.onChange(of: self.onboardingRequestID) { _, _ in
// Root-owned resets leave Settings mounted behind onboarding.
// Reload cleared credentials before the view can persist stale state.
@@ -202,46 +211,52 @@ struct SettingsProTab: View {
}
private func settingsModalPresentation(_ content: some View) -> some View {
content
let scanID = self.scannerScanID
return content
.sheet(isPresented: self.$showTalkIssueDetails) {
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
TalkRuntimeIssueDetailsSheet(issue: issue)
}
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
onError: { error in
self.showQRScanner = false
self.setupStatusText = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.font(OpenClawType.body)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
self.showQRScanner = false
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
.sheet(
isPresented: self.$showQRScanner,
onDismiss: {
self.processQueuedScannerResult()
},
content: {
NavigationStack {
QRScannerView(
onResult: { result in
self.queueScannedResult(result, scanID: scanID)
},
onError: { error in
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
self.setupStatusText = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.font(OpenClawType.body)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
self.scannerResultHandoff.cancel()
self.showQRScanner = false
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
}
}
}
})
.sheet(isPresented: self.$showNotificationRelayDisclosure) {
HostedPushRelayDisclosureSheet(
message: self.notificationRelayDisclosureMessage,
@@ -279,6 +294,7 @@ struct SettingsProTab: View {
}
private func applyGatewaySetupRequestIfNeeded() {
guard self.acceptsGatewaySetupRequests else { return }
guard let gatewaySetupRequest else { return }
self.applyGatewaySetupLink(gatewaySetupRequest.link)
self.onGatewaySetupRequestHandled?(gatewaySetupRequest.id)
@@ -156,8 +156,23 @@ extension SettingsProTab {
self.refreshLocationPermissionSummary()
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInstanceId.isEmpty else { return }
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
guard let stableID = self.currentManualGatewayStableID else {
self.gatewayCredentialFieldStableID = nil
self.gatewayToken = ""
self.gatewayPassword = ""
self.pendingManualAuthOverride = nil
return
}
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: trimmedInstanceId,
gatewayStableID: stableID)
let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth
self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil
self.gatewayToken = credentials.token ?? ""
self.gatewayPassword = credentials.password ?? ""
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: trimmedInstanceId,
targetStableID: stableID)
}
func refreshLocationPermissionSummary(desiredMode modeOverride: OpenClawLocationMode? = nil) {
@@ -196,12 +211,20 @@ extension SettingsProTab {
self.stagedGatewaySetupLink = nil
self.pendingManualAuthOverride = nil
self.syncSettingsState()
self.pendingTargetSuppression.releaseAutoConnect(controller: self.gatewayController)
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
let supersededSetupLease = self.takeStagedGatewaySetupSuppression()
defer {
if let supersededSetupLease {
self.gatewayController.resumeAutoConnect(after: supersededSetupLease)
}
}
self.connectingGatewayID = gateway.id
defer { self.connectingGatewayID = nil }
self.manualGatewayEnabled = false
self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
if let err = await self.gatewayController.connectWithDiagnostics(gateway) {
@@ -211,7 +234,10 @@ extension SettingsProTab {
func applySetupCodeAndConnect() async {
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
defer { self.finishGatewaySetupAttempt(attemptID) }
defer {
self.finishGatewaySetupAttempt(attemptID)
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
}
self.setupStatusText = nil
guard await self.applySetupCode(attemptID: attemptID) else { return }
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -225,6 +251,12 @@ extension SettingsProTab {
}
func applyGatewaySetupLink(_ link: GatewayConnectDeepLink) {
// Only the root-selected Gateway destination may destructively claim a
// setup link; other Settings views can remain mounted behind onboarding.
self.showQRScanner = false
self.scannerResultHandoff.cancel()
let lease = self.gatewayController.cancelPendingConnectionAttempts()
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
self.setupCode = ""
self.setupStatusText = nil
self.stagedGatewaySetupLink = link
@@ -246,6 +278,7 @@ extension SettingsProTab {
self.setupCode = ""
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
self.pendingTargetSuppression.releaseAutoConnect(.setupLink, controller: self.gatewayController)
return false
}
@@ -267,34 +300,58 @@ extension SettingsProTab {
self.manualGatewayTLS = link.tls
let instanceId = GatewaySettingsStore.currentInstanceID()
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
if setupAuth.hasBootstrapToken {
GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId)
GatewayOnboardingReset.prepareForBootstrapPairing(
appModel: self.appModel,
instanceId: instanceId,
gatewayStableID: setupAuth.targetStableID)
}
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId)
}
if setupAuth.shouldApplyTokenField {
self.gatewayToken = setupAuth.token
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId)
}
}
if setupAuth.shouldApplyPasswordField {
self.gatewayPassword = setupAuth.password
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId)
}
GatewaySettingsStore.saveGatewayCredentials(
token: setupAuth.token,
bootstrapToken: setupAuth.bootstrapToken,
password: setupAuth.password,
gatewayStableID: setupAuth.targetStableID,
suppressStoredDeviceAuth: true,
instanceId: instanceId)
}
self.gatewayToken = setupAuth.token
self.gatewayPassword = setupAuth.password
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
}
func openGatewayQRScanner() {
self.appModel.disconnectGateway()
self.invalidateGatewaySetupAttempt()
let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
self.stagedGatewaySetupLink = nil
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
self.scannerScanID = self.scannerResultHandoff.beginScan()
self.connectingGatewayID = nil
self.setupStatusText = "Opening QR scanner..."
self.showQRScanner = true
}
func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
self.setupStatusText = "QR loaded. Closing scanner..."
self.showQRScanner = false
}
func processQueuedScannerResult() {
let delivery = self.scannerResultHandoff.processAfterDismissal { result in
switch result {
case let .gatewayLink(link):
self.handleScannedGatewayLink(link)
case let .setupCode(code):
self.handleScannedSetupCode(code)
}
}
if delivery == nil {
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
}
}
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
self.showQRScanner = false
guard let attemptID = self.beginGatewaySetupAttempt() else { return }
@@ -309,10 +366,25 @@ extension SettingsProTab {
self.stagedGatewaySetupLink = nil
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController)
}
func clearStagedGatewaySetupLink() {
guard self.stagedGatewaySetupLink != nil else { return }
self.stagedGatewaySetupLink = nil
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
}
private func takeStagedGatewaySetupSuppression() -> GatewayConnectionController.AutoConnectSuppressionLease? {
self.stagedGatewaySetupLink = nil
return self.pendingTargetSuppression.take(ifOwnedBy: .setupLink)
}
func connectAfterScannedGatewayLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
defer { self.finishGatewaySetupAttempt(attemptID) }
defer {
self.finishGatewaySetupAttempt(attemptID)
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
}
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
self.applyGatewayLink(link)
@@ -332,6 +404,12 @@ extension SettingsProTab {
} else {
self.invalidateGatewaySetupAttempt()
}
let supersededSetupLease = self.takeStagedGatewaySetupSuppression()
defer {
if let supersededSetupLease {
self.gatewayController.resumeAutoConnect(after: supersededSetupLease)
}
}
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.setupStatusText = "Failed: host required"
@@ -341,19 +419,49 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
guard let port = self.resolvedManualPort(host: host) else {
self.setupStatusText = "Failed: invalid port"
return
}
self.connectingGatewayID = "manual"
self.manualGatewayEnabled = true
defer { self.connectingGatewayID = nil }
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID,
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
{
self.pendingManualAuthOverride = nil
}
let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID
let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID
? self.pendingManualAuthOverride
: nil
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: self.gatewayToken,
pendingOverride: self.pendingManualAuthOverride,
password: self.gatewayPassword)
self.pendingManualAuthOverride = nil
token: fieldsMatchTarget ? self.gatewayToken : nil,
pendingOverride: pendingOverride,
password: fieldsMatchTarget ? self.gatewayPassword : nil,
targetStableID: stableID)
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil {
GatewaySettingsStore.saveGatewayCredentials(
token: authOverride?.token,
bootstrapToken: authOverride?.bootstrapToken,
password: authOverride?.password,
gatewayStableID: stableID,
suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true,
instanceId: instanceId)
}
await self.gatewayController.connectManual(
host: host,
port: self.manualGatewayPort,
port: port,
useTLS: self.manualGatewayTLS,
authOverride: authOverride)
// The controller now owns this attempt's immutable override. A later retry must reload
// durable state so a spent bootstrap token cannot be resurrected from the live view.
self.pendingManualAuthOverride = nil
}
func preflightGateway(host: String) async -> Bool {
@@ -376,6 +484,8 @@ extension SettingsProTab {
defer { self.suppressCredentialPersist = false }
self.gatewayToken = ""
self.gatewayPassword = ""
self.gatewayCredentialFieldStableID = nil
self.pendingManualAuthOverride = nil
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
self.onboardingComplete = false
self.hasConnectedOnce = false
@@ -501,22 +611,88 @@ extension SettingsProTab {
self.notificationStatus = SettingsNotificationStatus(status)
}
var currentManualGatewayStableID: String? {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
return GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
}
var gatewayCredentialTargetStableID: String? {
// Auth fields follow the selected route. Otherwise a discovered-gateway retry can save
// credentials under the unrelated manual endpoint and immediately reload an empty bundle.
self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID
}
var manualGatewayEnabledBinding: Binding<Bool> {
Binding(
get: { self.manualGatewayEnabled },
set: { enabled in
self.manualGatewayEnabled = enabled
guard enabled, let stableID = self.currentManualGatewayStableID else { return }
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
})
}
var gatewayTokenBinding: Binding<String> {
Binding(
get: { self.gatewayToken },
set: { self.persistGatewayToken($0) })
}
var gatewayPasswordBinding: Binding<String> {
Binding(
get: { self.gatewayPassword },
set: { self.persistGatewayPassword($0) })
}
var manualHostBinding: Binding<String> {
Binding(
get: { self.manualGatewayHost },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualGatewayHost = value
if previousStableID != self.currentManualGatewayStableID {
self.clearManualCredentialFields()
}
})
}
func persistGatewayToken(_ value: String) {
self.gatewayToken = value
guard !self.suppressCredentialPersist else { return }
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayToken(
value.trimmingCharacters(in: .whitespacesAndNewlines),
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
self.gatewayCredentialFieldStableID = stableID
let saved = GatewaySettingsStore.updateGatewayCredentials(
token: value,
password: self.gatewayPassword,
gatewayStableID: stableID,
instanceId: instanceId)
self.pendingManualAuthOverride = saved
? GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
: nil
}
func persistGatewayPassword(_ value: String) {
self.gatewayPassword = value
guard !self.suppressCredentialPersist else { return }
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(
value.trimmingCharacters(in: .whitespacesAndNewlines),
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
self.gatewayCredentialFieldStableID = stableID
let saved = GatewaySettingsStore.updateGatewayCredentials(
token: self.gatewayToken,
password: value,
gatewayStableID: stableID,
instanceId: instanceId)
self.pendingManualAuthOverride = saved
? GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
: nil
}
func openNotificationSettings() {
@@ -543,27 +719,54 @@ extension SettingsProTab {
Binding(
get: { self.manualGatewayPortText },
set: { newValue in
let previousStableID = self.currentManualGatewayStableID
let filtered = newValue.filter(\.isNumber)
self.manualGatewayPortText = filtered
self.manualGatewayPort = Int(filtered) ?? 0
if previousStableID != self.currentManualGatewayStableID {
self.clearManualCredentialFields()
}
})
}
private func clearManualCredentialFields() {
self.gatewayToken = ""
self.gatewayPassword = ""
self.gatewayCredentialFieldStableID = nil
self.pendingManualAuthOverride = nil
}
private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) {
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if self.gatewayCredentialFieldStableID != stableID {
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceId,
gatewayStableID: stableID)
self.gatewayCredentialFieldStableID = stableID
self.gatewayToken = credentials.token ?? ""
self.gatewayPassword = credentials.password ?? ""
}
guard allowManualOverride else {
self.pendingManualAuthOverride = nil
return
}
// Each attempt consumes the in-memory override. Reload durable bootstrap auth even
// when the endpoint fields did not change so retry never erases a one-time token.
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
}
var manualPortIsValid: Bool {
if self.manualGatewayPortText.isEmpty { return true }
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
}
func resolvedManualPort(host: String) -> Int? {
if self.manualGatewayPort > 0 {
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
}
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
return 443
}
return 18789
guard self.manualGatewayPortText.isEmpty || self.manualGatewayPort > 0 else { return nil }
return GatewayConnectionController.resolvedManualPort(
host: host,
port: self.manualGatewayPort)
}
var setupStatusLine: String? {
@@ -784,11 +784,11 @@ extension SettingsProTab {
var manualGatewayCard: some View {
Section("Manual Gateway") {
Toggle(isOn: self.$manualGatewayEnabled) {
Toggle(isOn: self.manualGatewayEnabledBinding) {
Text("Use Manual Gateway")
.font(OpenClawType.body)
}
TextField("Host", text: self.$manualGatewayHost)
TextField("Host", text: self.manualHostBinding)
.font(OpenClawType.body)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -819,8 +819,8 @@ extension SettingsProTab {
Text("Auto-connect on launch")
.font(OpenClawType.body)
}
self.gatewaySecureField("Gateway Auth Token", text: self.$gatewayToken)
self.gatewaySecureField("Gateway Password", text: self.$gatewayPassword)
self.gatewaySecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
self.gatewaySecureField("Gateway Password", text: self.gatewayPasswordBinding)
Button(role: .destructive) {
self.showResetOnboardingAlert = true
} label: {
@@ -7,8 +7,8 @@ import OpenClawKit
/// - a `role=node` session for device capabilities (`node.invoke.*`)
/// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.)
///
/// Both sessions should derive all connection inputs from this config so we
/// don't accidentally persist gateway-scoped state under different keys.
/// Both sessions derive routing and authentication ownership from the route's
/// `stableID`. TLS certificate pins prove transport trust but are not gateway identity.
struct GatewayConnectConfig {
let url: URL
let stableID: String
@@ -18,7 +18,7 @@ struct GatewayConnectConfig {
let password: String?
let nodeOptions: GatewayConnectOptions
/// Stable, non-empty identifier used for gateway-scoped persistence keys.
/// Stable, non-empty route identifier used for UI/event ownership.
/// If the caller doesn't provide a stableID, fall back to URL identity.
var effectiveStableID: String {
let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -64,6 +64,8 @@ struct GatewayConnectConfig {
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhs.allowStoredDeviceAuth == rhs.allowStoredDeviceAuth &&
lhs.deviceAuthGatewayID == rhs.deviceAuthGatewayID &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&
lhsCommands == rhsCommands &&
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import Foundation
import OpenClawKit
import os
enum GatewaySettingsStore {
@@ -23,6 +24,38 @@ enum GatewaySettingsStore {
private static let lastGatewayConnectionAccount = "lastConnection"
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." // pragma: allowlist secret
struct GatewayCredentialMetadata: Codable, Equatable {
let gatewayStableID: String
let suppressStoredDeviceAuth: Bool
}
/// Credential ownership and secrets must move together. Separate Keychain
/// entries can survive a partial update and bind one gateway's secret to another.
private struct GatewayCredentialBundle: Codable {
let gatewayStableID: String
let suppressStoredDeviceAuth: Bool
let token: String?
let bootstrapToken: String?
let password: String?
}
struct GatewayCredentials: Equatable {
let token: String?
let bootstrapToken: String?
let password: String?
let suppressStoredDeviceAuth: Bool
static let empty = GatewayCredentials(
token: nil,
bootstrapToken: nil,
password: nil,
suppressStoredDeviceAuth: false)
var hasCredentials: Bool {
self.token != nil || self.bootstrapToken != nil || self.password != nil
}
}
static func bootstrapPersistence() {
self.ensureStableInstanceID()
self.ensurePreferredGatewayStableID()
@@ -107,75 +140,172 @@ enum GatewaySettingsStore {
defaults.removeObject(forKey: self.lastDiscoveredGatewayStableIDDefaultsKey)
}
static func loadGatewayToken(instanceId: String) -> String? {
let account = self.gatewayTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if token?.isEmpty == false { return token }
return nil
static func loadGatewayCredentialMetadata(instanceId: String) -> GatewayCredentialMetadata? {
guard let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId) else { return nil }
return GatewayCredentialMetadata(
gatewayStableID: bundle.gatewayStableID,
suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth)
}
static func saveGatewayToken(_ token: String, instanceId: String) {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: instanceId))
return
static func loadGatewayCredentials(instanceId: String, gatewayStableID: String) -> GatewayCredentials {
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
guard !stableID.isEmpty,
let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId),
bundle.gatewayStableID == stableID
else { return .empty }
return GatewayCredentials(
token: bundle.token,
bootstrapToken: bundle.bootstrapToken,
password: bundle.password,
suppressStoredDeviceAuth: bundle.suppressStoredDeviceAuth)
}
@discardableResult
static func saveGatewayCredentials(
token: String?,
bootstrapToken: String?,
password: String?,
gatewayStableID: String,
suppressStoredDeviceAuth: Bool,
instanceId: String) -> Bool
{
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty, !trimmedInstanceID.isEmpty else { return false }
let bundle = GatewayCredentialBundle(
gatewayStableID: stableID,
suppressStoredDeviceAuth: suppressStoredDeviceAuth,
token: self.normalizedCredential(token),
bootstrapToken: self.normalizedCredential(bootstrapToken),
password: self.normalizedCredential(password))
let account = self.gatewayCredentialBundleAccount(instanceId: trimmedInstanceID)
let hasCredentials = bundle.token != nil || bundle.bootstrapToken != nil || bundle.password != nil
guard hasCredentials || suppressStoredDeviceAuth else {
let deleted = KeychainStore.delete(service: self.gatewayService, account: account)
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
return deleted || KeychainStore.loadString(service: self.gatewayService, account: account) == nil
}
guard let data = try? JSONEncoder().encode(bundle),
let json = String(data: data, encoding: .utf8)
else {
_ = KeychainStore.delete(service: self.gatewayService, account: account)
return false
}
guard KeychainStore.saveString(
json,
service: self.gatewayService,
account: account)
else {
// The Keychain helper restores the prior item when replacement fails. Keep that
// known-good bundle; callers already treat this attempted update as uncommitted.
return false
}
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
return true
}
@discardableResult
static func updateGatewayCredentials(
token: String?,
password: String?,
gatewayStableID: String,
instanceId: String) -> Bool
{
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
let existing = self.loadGatewayCredentialBundle(instanceId: instanceId)
let sameOwner = existing?.gatewayStableID == stableID
return self.saveGatewayCredentials(
token: token,
bootstrapToken: sameOwner ? existing?.bootstrapToken : nil,
password: password,
gatewayStableID: stableID,
suppressStoredDeviceAuth: sameOwner && existing?.suppressStoredDeviceAuth == true,
instanceId: instanceId)
}
@discardableResult
static func completeGatewayCredentialHandoff(instanceId: String, gatewayStableID: String) -> Bool {
let stableID = self.authenticationOwnerID(routeStableID: gatewayStableID)
guard let bundle = self.loadGatewayCredentialBundle(instanceId: instanceId),
bundle.gatewayStableID == stableID,
bundle.suppressStoredDeviceAuth
else { return false }
// Device-token issuance and bootstrap consumption are one durable handoff. A relaunch
// must never observe a spent bootstrap token while stored device auth remains disabled.
return self.saveGatewayCredentials(
token: bundle.token,
bootstrapToken: nil,
password: bundle.password,
gatewayStableID: stableID,
suppressStoredDeviceAuth: false,
instanceId: instanceId)
}
static func discardUnscopedGatewayCredentials(instanceId: String) {
// The legacy UI saved fields before a successful connection, so the last route
// cannot prove who owns these secrets. Re-entry is safer than cross-gateway reuse.
self.deleteLegacyGatewayCredentials(instanceId: instanceId)
}
/// Certificate pins prove transport trust for one route; they are not gateway identities.
/// Wildcard certificates and reverse proxies may legitimately reuse a leaf certificate.
static func authenticationOwnerID(routeStableID: String) -> String {
routeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
}
@discardableResult
static func migrateProvenRelayCredentials(
instanceId: String,
gatewayStableID: String,
token: String?,
password: String?) -> Bool
{
let trimmedInstanceID = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInstanceID.isEmpty, !stableID.isEmpty else { return false }
let legacyAccounts = [
self.gatewayTokenAccount(instanceId: trimmedInstanceID),
self.gatewayBootstrapTokenAccount(instanceId: trimmedInstanceID),
self.gatewayPasswordAccount(instanceId: trimmedInstanceID),
]
let hasLegacyCredentials = legacyAccounts.contains { account in
self.normalizedCredential(KeychainStore.loadString(
service: self.gatewayService,
account: account)) != nil
}
guard hasLegacyCredentials else { return true }
// A canonical bundle already owns the fields atomically. Never replace it with
// older relay data merely because legacy per-field entries still exist.
if self.loadGatewayCredentialBundle(instanceId: trimmedInstanceID) != nil {
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
return true
}
let relayToken = self.normalizedCredential(token)
let relayPassword = self.normalizedCredential(password)
guard relayToken != nil || relayPassword != nil else {
self.deleteLegacyGatewayCredentials(instanceId: trimmedInstanceID)
return true
}
// Relay config is written only after a successful connection and therefore proves
// both the credential values and their gateway owner. Preserve it before cleanup.
return self.saveGatewayCredentials(
token: relayToken,
bootstrapToken: nil,
password: relayPassword,
gatewayStableID: stableID,
suppressStoredDeviceAuth: false,
instanceId: trimmedInstanceID)
}
static func saveLegacyGatewayTokenForMigrationTest(_ token: String, instanceId: String) {
_ = KeychainStore.saveString(
trimmed,
token,
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: instanceId))
}
static func loadGatewayBootstrapToken(instanceId: String) -> String? {
let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId)
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if token?.isEmpty == false { return token }
return nil
}
static func saveGatewayBootstrapToken(_ token: String, instanceId: String) {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
self.clearGatewayBootstrapToken(instanceId: instanceId)
return
}
_ = KeychainStore.saveString(
trimmed,
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func clearGatewayBootstrapToken(instanceId: String) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
}
static func loadGatewayPassword(instanceId: String) -> String? {
KeychainStore.loadString(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))?
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static func saveGatewayPassword(_ password: String, instanceId: String) {
let trimmed = password.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))
return
}
_ = KeychainStore.saveString(
trimmed,
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))
}
enum LastGatewayConnection: Equatable {
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
case discovered(stableID: String, useTLS: Bool)
@@ -308,13 +438,8 @@ enum GatewaySettingsStore {
guard !trimmed.isEmpty else { return }
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: trimmed))
account: self.gatewayCredentialBundleAccount(instanceId: trimmed))
self.deleteLegacyGatewayCredentials(instanceId: trimmed)
}
static func loadGatewayClientIdOverride(stableID: String) -> String? {
@@ -373,6 +498,47 @@ enum GatewaySettingsStore {
"gateway-password.\(instanceId)"
}
private static func gatewayCredentialBundleAccount(instanceId: String) -> String {
"gateway-credentials.\(instanceId)"
}
private static func loadGatewayCredentialBundle(instanceId: String) -> GatewayCredentialBundle? {
guard let json = KeychainStore.loadString(
service: self.gatewayService,
account: self.gatewayCredentialBundleAccount(instanceId: instanceId)),
let data = json.data(using: .utf8),
let decoded = try? JSONDecoder().decode(GatewayCredentialBundle.self, from: data)
else { return nil }
let stableID = decoded.gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return nil }
return GatewayCredentialBundle(
gatewayStableID: stableID,
suppressStoredDeviceAuth: decoded.suppressStoredDeviceAuth,
token: self.normalizedCredential(decoded.token),
bootstrapToken: self.normalizedCredential(decoded.bootstrapToken),
password: self.normalizedCredential(decoded.password))
}
private static func normalizedCredential(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func deleteLegacyGatewayCredentials(instanceId: String) {
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayTokenAccount(instanceId: instanceId))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
_ = KeychainStore.delete(
service: self.gatewayService,
account: self.gatewayPasswordAccount(instanceId: instanceId))
_ = KeychainStore.delete(
service: self.gatewayService,
account: "gateway-credential-metadata.\(instanceId)")
}
private static func talkProviderApiKeyAccount(providerId: String) -> String {
self.talkProviderApiKeyAccountPrefix + providerId
}
@@ -460,9 +626,10 @@ enum GatewayDiagnostics {
func failed(_ stage: String, error: Error) {
let nsError = error as NSError
let errorType = String(reflecting: type(of: error))
self
.stage(
"\(stage) failed errorType=\(String(reflecting: type(of: error))) domain=\(nsError.domain) code=\(nsError.code)")
"\(stage) failed errorType=\(errorType) domain=\(nsError.domain) code=\(nsError.code)")
}
}
@@ -2,12 +2,13 @@ import SwiftUI
struct GatewayTrustPromptAlert: ViewModifier {
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
let isEnabled: Bool
func body(content: Content) -> some View {
content.alert(
"Trust this gateway?",
isPresented: Binding(
get: { self.gatewayController.pendingTrustPrompt != nil },
get: { self.isEnabled && self.gatewayController.pendingTrustPrompt != nil },
set: { _ in
// Keep pending trust state until explicit user action.
// SwiftUI may set presentation bindings during dismissal; clearing here can
@@ -39,7 +40,7 @@ struct GatewayTrustPromptAlert: ViewModifier {
}
extension View {
func gatewayTrustPromptAlert() -> some View {
self.modifier(GatewayTrustPromptAlert())
func gatewayTrustPromptAlert(isEnabled: Bool = true) -> some View {
self.modifier(GatewayTrustPromptAlert(isEnabled: isEnabled))
}
}
@@ -8,6 +8,7 @@ extension NodeAppModel {
normalized.body = params.body.trimmingCharacters(in: .whitespacesAndNewlines)
normalized.promptId = self.trimmedOrNil(params.promptId)
normalized.sessionKey = self.trimmedOrNil(params.sessionKey)
normalized.gatewayStableID = self.trimmedOrNil(params.gatewayStableID)
normalized.kind = self.trimmedOrNil(params.kind)
normalized.details = self.trimmedOrNil(params.details)
normalized.priority = self.normalizedWatchPriority(params.priority, risk: params.risk)
File diff suppressed because it is too large Load Diff
@@ -6,25 +6,16 @@ enum GatewayOnboardingReset {
static func prepareForBootstrapPairing(
appModel: NodeAppModel,
instanceId: String,
gatewayStableID: String,
disconnectGateway: Bool = true,
defaults: UserDefaults = .standard)
{
appModel.disconnectGateway()
let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
}
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)
GatewaySettingsStore.clearLastDiscoveredGatewayStableID(defaults: defaults)
GatewayTLSStore.clearAllFingerprints()
defaults.set(false, forKey: "gateway.autoconnect")
self.prepare(
appModel: appModel,
instanceId: instanceId,
gatewayStableID: gatewayStableID,
disconnectGateway: disconnectGateway,
defaults: defaults)
}
@MainActor
@@ -33,7 +24,12 @@ enum GatewayOnboardingReset {
instanceId: String,
defaults: UserDefaults = .standard)
{
self.prepareForBootstrapPairing(appModel: appModel, instanceId: instanceId, defaults: defaults)
self.prepare(
appModel: appModel,
instanceId: instanceId,
gatewayStableID: nil,
disconnectGateway: true,
defaults: defaults)
OnboardingStateStore.reset(defaults: defaults)
defaults.set(false, forKey: "gateway.onboardingComplete")
@@ -43,4 +39,55 @@ enum GatewayOnboardingReset {
defaults.set("", forKey: "gateway.setupCode")
defaults.set(defaults.integer(forKey: "onboarding.requestID") + 1, forKey: "onboarding.requestID")
}
@MainActor
private static func prepare(
appModel: NodeAppModel,
instanceId: String,
gatewayStableID: String?,
disconnectGateway: Bool,
defaults: UserDefaults)
{
if disconnectGateway {
appModel.disconnectGateway()
}
let trimmedInstanceId = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
}
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
if let gatewayStableID {
let authenticationOwnerID = GatewaySettingsStore.authenticationOwnerID(
routeStableID: gatewayStableID)
let shareDeviceId = DeviceIdentityStore.loadOrCreate(profile: .shareExtension).deviceId
// Bootstrap replacement invalidates only the target. Other paired gateways remain
// usable when the user switches back after reviewing or completing this setup.
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node", gatewayID: authenticationOwnerID)
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator", gatewayID: authenticationOwnerID)
DeviceAuthStore.clearToken(
deviceId: shareDeviceId,
role: "node",
gatewayID: authenticationOwnerID,
profile: .shareExtension)
DeviceAuthStore.clearToken(
deviceId: shareDeviceId,
role: "operator",
gatewayID: authenticationOwnerID,
profile: .shareExtension)
GatewayTLSStore.clearFingerprint(stableID: gatewayStableID)
} else {
// Full onboarding reset is the only path that intentionally forgets every gateway.
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewayTLSStore.clearAllFingerprints()
}
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)
GatewaySettingsStore.clearLastDiscoveredGatewayStableID(defaults: defaults)
defaults.set(false, forKey: "gateway.autoconnect")
}
}
@@ -40,6 +40,26 @@ private enum OnboardingStep: Int, CaseIterable {
}
}
struct GatewaySetupLinkStaging {
private(set) var link: GatewayConnectDeepLink?
mutating func stage(_ link: GatewayConnectDeepLink) {
self.link = link
}
mutating func take() -> GatewayConnectDeepLink? {
defer { self.link = nil }
return self.link
}
@discardableResult
mutating func cancel() -> Bool {
guard self.link != nil else { return false }
self.link = nil
return true
}
}
struct OnboardingWizardView: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@@ -55,6 +75,7 @@ struct OnboardingWizardView: View {
@State private var manualTLS: Bool = true
@State private var gatewayToken: String = ""
@State private var gatewayPassword: String = ""
@State private var gatewayCredentialFieldStableID: String?
@State private var connectMessage: String?
@State private var statusLine: String = ""
@State private var connectingGatewayID: String?
@@ -64,10 +85,14 @@ struct OnboardingWizardView: View {
@State private var discoveryRestartTask: Task<Void, Never>?
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var scannerResultHandoff = QRScannerResultHandoff()
@State private var scannerScanID: UInt64 = 0
@State private var pendingTargetSuppression = GatewayPendingTargetSuppression()
@State private var selectedPhoto: PhotosPickerItem?
@State private var showGatewayProblemDetails: Bool = false
@State private var lastPairingAutoResumeAttemptAt: Date?
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State private var setupLinkStaging = GatewaySetupLinkStaging()
@State private var setupCode: String = ""
@State private var setupCodeStatus: String?
@State private var setupAttemptID: UUID?
@@ -101,6 +126,18 @@ struct OnboardingWizardView: View {
}
var body: some View {
self.lifecycleContent
.onChange(of: self.scenePhase) { _, newValue in
guard newValue == ScenePhase.active else { return }
self.applyPendingGatewaySetupLinkIfNeeded()
self.attemptAutomaticPairingResumeIfNeeded()
}
.onReceive(Self.pairingAutoResumeTicker) { _ in
self.attemptAutomaticPairingResumeIfNeeded()
}
}
private var lifecycleContent: some View {
NavigationStack {
Group {
switch self.step {
@@ -189,73 +226,14 @@ struct OnboardingWizardView: View {
Text(self.scannerError ?? "")
.font(OpenClawType.subhead)
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
onError: { error in
self.showQRScanner = false
self.statusLine = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Scan QR Code")
.font(OpenClawType.headline)
}
ToolbarItem(placement: .topBarLeading) {
Button {
self.showQRScanner = false
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
ToolbarItem(placement: .topBarTrailing) {
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
Label("Photos", systemImage: "photo")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
}
}
.onChange(of: self.selectedPhoto) { _, newValue in
guard let item = newValue else { return }
self.selectedPhoto = nil
Task {
guard let data = try? await item.loadTransferable(type: Data.self) else {
self.showQRScanner = false
self.scannerError = "Could not load the selected image."
return
}
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupInput(message) {
self.handleScannedLink(link)
return
}
if AppleReviewDemoMode.isSetupCode(message) {
self.handleScannedSetupCode(message)
return
}
}
self.showQRScanner = false
self.scannerError = "No valid QR code found in the selected image."
}
}
}
.sheet(
isPresented: self.$showQRScanner,
onDismiss: {
self.processQueuedScannerResult()
},
content: {
self.qrScannerSheet
})
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let currentProblem = self.currentProblem {
GatewayProblemDetailsSheet(
@@ -268,12 +246,15 @@ struct OnboardingWizardView: View {
}
.onAppear {
self.initializeState()
self.applyPendingGatewaySetupLinkIfNeeded()
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
}
.onDisappear {
self.invalidateSetupAttempt()
self.discoveryRestartTask?.cancel()
self.discoveryRestartTask = nil
self.scannerResultHandoff.cancel()
self.pendingTargetSuppression.resumeAutoConnect(controller: self.gatewayController)
}
.onChange(of: self.discoveryDomain) { _, _ in
self.scheduleDiscoveryRestart()
@@ -296,11 +277,9 @@ struct OnboardingWizardView: View {
self.manualPortText = normalized
}
}
.onChange(of: self.gatewayToken) { _, newValue in
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
}
.onChange(of: self.gatewayPassword) { _, newValue in
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
.onChange(of: self.setupCode) { _, newValue in
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
self.clearStagedGatewaySetupLink()
}
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
@@ -308,8 +287,11 @@ struct OnboardingWizardView: View {
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
guard newValue != nil, self.setupLinkStaging.link == nil else { return }
self.showQRScanner = false
self.statusLine = "Connected."
if !self.didMarkCompleted, let selectedMode {
@@ -318,13 +300,76 @@ struct OnboardingWizardView: View {
}
self.step = .success
}
.onChange(of: self.scenePhase) { _, newValue in
guard newValue == .active else { return }
self.attemptAutomaticPairingResumeIfNeeded()
}
.onReceive(Self.pairingAutoResumeTicker) { _ in
self.attemptAutomaticPairingResumeIfNeeded()
}
private var qrScannerSheet: some View {
let scanID = self.scannerScanID
return NavigationStack {
QRScannerView(
onResult: { result in
self.queueScannedResult(result, scanID: scanID)
},
onError: { error in
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
self.statusLine = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Scan QR Code")
.font(OpenClawType.headline)
}
ToolbarItem(placement: .topBarLeading) {
Button {
self.scannerResultHandoff.cancel()
self.showQRScanner = false
} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
}
ToolbarItem(placement: .topBarTrailing) {
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
Label("Photos", systemImage: "photo")
.font(OpenClawType.subheadSemiBold)
}
}
}
}
.onChange(of: self.selectedPhoto) { _, newValue in
guard let item = newValue else { return }
self.selectedPhoto = nil
Task {
guard let data = try? await item.loadTransferable(type: Data.self) else {
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
self.showQRScanner = false
self.scannerError = "Could not load the selected image."
return
}
guard self.scannerResultHandoff.isActive(scanID: scanID) else { return }
if let message = self.detectQRCode(from: data) {
if let link = GatewayConnectDeepLink.fromSetupInput(message) {
self.queueScannedResult(.gatewayLink(link), scanID: scanID)
return
}
if AppleReviewDemoMode.isSetupCode(message) {
self.queueScannedResult(.setupCode(message), scanID: scanID)
return
}
}
self.showQRScanner = false
self.scannerError = "No valid QR code found in the selected image."
}
}
}
private var introStep: some View {
@@ -443,13 +488,17 @@ struct OnboardingWizardView: View {
}
}
switch selectedMode {
case .homeNetwork:
self.homeNetworkConnectSection
case .remoteDomain:
self.remoteDomainConnectSection
case .developerLocal:
self.developerConnectSection
if let stagedLink = self.setupLinkStaging.link {
self.stagedGatewaySetupSection(stagedLink)
} else {
switch selectedMode {
case .homeNetwork:
self.homeNetworkConnectSection
case .remoteDomain:
self.remoteDomainConnectSection
case .developerLocal:
self.developerConnectSection
}
}
} else {
Section {
@@ -466,6 +515,50 @@ struct OnboardingWizardView: View {
}
}
private func stagedGatewaySetupSection(_ link: GatewayConnectDeepLink) -> some View {
Section {
self.onboardingLabeledContent("Host", value: link.host)
self.onboardingLabeledContent("Port", value: String(link.port))
self.onboardingLabeledContent("Security", value: link.tls ? "TLS" : "Plaintext (local network)")
Button {
Task { await self.connectStagedGatewaySetupLink() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
.font(OpenClawType.subheadSemiBold)
}
} else {
Text("Connect")
.font(OpenClawType.subheadSemiBold)
}
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
Button {
self.clearStagedGatewaySetupLink()
} label: {
Text("Use Manual Setup")
.font(OpenClawType.subheadSemiBold)
}
.font(OpenClawType.subheadSemiBold)
.disabled(self.connectingGatewayID != nil)
} header: {
Text("Setup Link")
.font(OpenClawType.captionSemiBold)
} footer: {
Text(link.tls
? "Review this endpoint. Credentials are applied only after you tap Connect."
:
"Plaintext may expose credentials. Continue only if you trust this local network and host.")
.font(OpenClawType.caption)
}
}
private var homeNetworkConnectSection: some View {
Group {
Section {
@@ -531,11 +624,11 @@ struct OnboardingWizardView: View {
private var developerConnectSection: some View {
Section {
TextField("Host", text: self.$manualHost)
TextField("Host", text: self.manualHostBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(OpenClawType.subhead)
TextField("Port", text: self.$manualPortText)
TextField("Port", text: self.manualPortTextBinding)
.keyboardType(.numberPad)
.font(OpenClawType.subhead)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
@@ -552,10 +645,10 @@ struct OnboardingWizardView: View {
private var authStep: some View {
Group {
Section {
self.onboardingSecureField("Gateway Auth Token", text: self.$gatewayToken)
self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
self.onboardingSecureField("Gateway Password", text: self.$gatewayPassword)
self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding)
if let problem = self.currentProblem {
GatewayProblemBanner(
@@ -721,11 +814,11 @@ extension OnboardingWizardView {
private func manualConnectionFieldsSection(title: String) -> some View {
Section {
TextField("Host", text: self.$manualHost)
TextField("Host", text: self.manualHostBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(OpenClawType.subhead)
TextField("Port", text: self.$manualPortText)
TextField("Port", text: self.manualPortTextBinding)
.keyboardType(.numberPad)
.font(OpenClawType.subhead)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
@@ -734,10 +827,10 @@ extension OnboardingWizardView {
.autocorrectionDisabled()
.font(OpenClawType.subhead)
if self.selectedMode == .remoteDomain {
self.onboardingSecureField("Gateway Auth Token", text: self.$gatewayToken)
self.onboardingSecureField("Gateway Auth Token", text: self.gatewayTokenBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
self.onboardingSecureField("Gateway Password", text: self.$gatewayPassword)
self.onboardingSecureField("Gateway Password", text: self.gatewayPasswordBinding)
}
self.manualConnectButton
} header: {
@@ -800,6 +893,7 @@ extension OnboardingWizardView {
self.setupCodeStatus = "Paste a setup code to continue."
return
}
self.clearStagedGatewaySetupLink()
if AppleReviewDemoMode.isSetupCode(raw) {
self.setupCode = ""
@@ -827,6 +921,26 @@ extension OnboardingWizardView {
await self.connectManual(setupAttemptID: attemptID)
}
private func queueScannedResult(_ result: QRScannerResult, scanID: UInt64) {
guard self.scannerResultHandoff.queue(result, scanID: scanID) else { return }
self.statusLine = "QR loaded. Closing scanner..."
self.showQRScanner = false
}
private func processQueuedScannerResult() {
let delivery = self.scannerResultHandoff.processAfterDismissal { result in
switch result {
case let .gatewayLink(link):
self.handleScannedLink(link)
case let .setupCode(code):
self.handleScannedSetupCode(code)
}
}
if delivery == nil {
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
}
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
self.showQRScanner = false
guard let attemptID = self.beginSetupAttempt() else { return }
@@ -835,7 +949,10 @@ extension OnboardingWizardView {
}
private func connectScannedLink(_ parsedLink: GatewayConnectDeepLink, attemptID: UUID) async {
defer { self.finishSetupAttempt(attemptID) }
defer {
self.finishSetupAttempt(attemptID)
self.pendingTargetSuppression.resumeAutoConnect(.qrScanner, controller: self.gatewayController)
}
let link = await self.gatewayController.selectReachableSetupLink(parsedLink)
guard self.setupAttemptID == attemptID else { return }
self.applyGatewayLink(link)
@@ -845,26 +962,86 @@ extension OnboardingWizardView {
await self.connectManual(setupAttemptID: attemptID)
}
private func applyGatewayLink(_ link: GatewayConnectDeepLink) {
private func applyPendingGatewaySetupLinkIfNeeded() {
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
self.showQRScanner = false
self.scannerResultHandoff.cancel()
self.showGatewayProblemDetails = false
let lease = self.gatewayController.cancelPendingConnectionAttempts()
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
self.setupLinkStaging.stage(link)
self.setupCodeStatus = "Setup link loaded for \(link.host):\(link.port). Tap Connect to apply."
self.connectMessage = nil
self.statusLine = self.setupCodeStatus ?? ""
self.step = .connect
}
private func connectStagedGatewaySetupLink() async {
guard self.connectingGatewayID == nil else { return }
guard let link = self.setupLinkStaging.link else { return }
guard link.isValidEndpoint else {
let message = "Setup link has an invalid gateway endpoint."
self.setupCodeStatus = message
self.statusLine = message
return
}
self.connectingGatewayID = "manual"
defer { self.connectingGatewayID = nil }
let lease = self.gatewayController.cancelPendingConnectionAttempts()
self.pendingTargetSuppression.replace(owner: .setupLink, lease: lease)
defer { self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController) }
await self.appModel.resetGatewaySessionsForTargetSwitch()
guard self.setupLinkStaging.link == link else { return }
_ = self.setupLinkStaging.take()
self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)
self.setupCodeStatus = "Setup link applied. Connecting..."
self.issue = .none
self.connectMessage = "Connecting to \(link.host)"
self.statusLine = "Connecting to \(link.host):\(link.port)"
await self.connectCurrentManualGateway(host: link.host, port: link.port, forceReconnect: false)
}
private func clearStagedGatewaySetupLink() {
guard self.setupLinkStaging.cancel() else { return }
self.pendingTargetSuppression.resumeAutoConnect(.setupLink, controller: self.gatewayController)
let message = "Setup link cleared."
self.setupCodeStatus = message
self.statusLine = message
}
private func applyGatewayLink(
_ link: GatewayConnectDeepLink,
disconnectExistingGatewayForBootstrap: Bool = true)
{
self.manualHost = link.host
self.manualPort = link.port
self.manualPortText = String(link.port)
self.manualTLS = link.tls
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
self.gatewayCredentialFieldStableID = setupAuth.targetStableID
if setupAuth.hasBootstrapToken {
GatewayOnboardingReset.prepareForBootstrapPairing(
appModel: self.appModel,
instanceId: GatewaySettingsStore.currentInstanceID())
}
self.saveGatewayBootstrapToken(setupAuth.bootstrapToken)
if setupAuth.shouldApplyTokenField {
self.gatewayToken = setupAuth.token
}
if setupAuth.shouldApplyPasswordField {
self.gatewayPassword = setupAuth.password
instanceId: GatewaySettingsStore.currentInstanceID(),
gatewayStableID: setupAuth.targetStableID,
disconnectGateway: disconnectExistingGatewayForBootstrap)
}
self.gatewayToken = setupAuth.token
self.gatewayPassword = setupAuth.password
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayCredentials(
token: setupAuth.token,
bootstrapToken: setupAuth.bootstrapToken,
password: setupAuth.password,
gatewayStableID: setupAuth.targetStableID,
suppressStoredDeviceAuth: true,
instanceId: instanceId)
}
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
@@ -878,16 +1055,21 @@ extension OnboardingWizardView {
self.statusLine = "Apple Review demo mode enabled."
self.selectedMode = .homeNetwork
self.appModel.enterAppleReviewDemoMode()
self.pendingTargetSuppression.releaseAutoConnect(.qrScanner, controller: self.gatewayController)
}
private func openQRScannerFromOnboarding() {
private func openQRScannerFromOnboarding(status: String = "Opening QR scanner…") {
// Stop active reconnect loops before scanning new credentials.
self.appModel.disconnectGateway()
self.invalidateSetupAttempt()
let lease = self.gatewayController.cancelPendingConnectionAttempts(suspendCurrentGateway: true)
_ = self.setupLinkStaging.cancel()
self.pendingTargetSuppression.replace(owner: .qrScanner, lease: lease)
self.scannerScanID = self.scannerResultHandoff.beginScan()
self.connectingGatewayID = nil
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
self.statusLine = "Opening QR scanner…"
self.statusLine = status
self.showQRScanner = true
}
@@ -1024,7 +1206,7 @@ extension OnboardingWizardView {
private var canConnectManual: Bool {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
return !host.isEmpty && self.resolvedManualPort(host: host) != nil
}
private var successEndpoint: String {
@@ -1065,9 +1247,19 @@ extension OnboardingWizardView {
}
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedInstanceId.isEmpty {
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
if !trimmedInstanceId.isEmpty,
let stableID = self.currentManualGatewayStableID
{
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: trimmedInstanceId,
gatewayStableID: stableID)
let ownsFields = credentials.hasCredentials || credentials.suppressStoredDeviceAuth
self.gatewayCredentialFieldStableID = ownsFields ? stableID : nil
self.gatewayToken = credentials.token ?? ""
self.gatewayPassword = credentials.password ?? ""
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: trimmedInstanceId,
targetStableID: stableID)
}
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
@@ -1087,23 +1279,129 @@ extension OnboardingWizardView {
}
}
private func saveGatewayCredentials(token: String, password: String) {
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
guard !trimmedInstanceId.isEmpty else { return }
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
private var currentManualGatewayStableID: String? {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return nil }
return GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
}
private func saveGatewayBootstrapToken(_ token: String?) {
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
guard !trimmedInstanceId.isEmpty else { return }
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId)
private var gatewayCredentialTargetStableID: String? {
// Auth fields follow the selected route. Otherwise a discovered-gateway retry can save
// credentials under the unrelated manual endpoint and immediately reload an empty bundle.
self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID
}
private func resolvedManualPort(host: String) -> Int? {
guard self.manualPortText.isEmpty || self.manualPort > 0 else { return nil }
return GatewayConnectionController.resolvedManualPort(
host: host,
port: self.manualPort)
}
private var gatewayTokenBinding: Binding<String> {
Binding(
get: { self.gatewayToken },
set: { self.persistGatewayToken($0) })
}
private var gatewayPasswordBinding: Binding<String> {
Binding(
get: { self.gatewayPassword },
set: { self.persistGatewayPassword($0) })
}
private var manualHostBinding: Binding<String> {
Binding(
get: { self.manualHost },
set: { value in
let previousStableID = self.currentManualGatewayStableID
self.manualHost = value
if previousStableID != self.currentManualGatewayStableID {
self.clearManualCredentialFields()
}
})
}
private var manualPortTextBinding: Binding<String> {
Binding(
get: { self.manualPortText },
set: { value in
let previousStableID = self.currentManualGatewayStableID
let digits = value.filter(\.isNumber)
self.manualPortText = digits
self.manualPort = min(Int(digits) ?? 0, 65535)
if previousStableID != self.currentManualGatewayStableID {
self.clearManualCredentialFields()
}
})
}
private func persistGatewayToken(_ value: String) {
self.gatewayToken = value
let instanceId = GatewaySettingsStore.currentInstanceID()
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
self.gatewayCredentialFieldStableID = stableID
let saved = GatewaySettingsStore.updateGatewayCredentials(
token: value,
password: self.gatewayPassword,
gatewayStableID: stableID,
instanceId: instanceId)
self.pendingManualAuthOverride = saved
? GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
: nil
}
private func persistGatewayPassword(_ value: String) {
self.gatewayPassword = value
let instanceId = GatewaySettingsStore.currentInstanceID()
guard !instanceId.isEmpty, let stableID = self.gatewayCredentialTargetStableID else { return }
self.gatewayCredentialFieldStableID = stableID
let saved = GatewaySettingsStore.updateGatewayCredentials(
token: self.gatewayToken,
password: value,
gatewayStableID: stableID,
instanceId: instanceId)
self.pendingManualAuthOverride = saved
? GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
: nil
}
private func clearManualCredentialFields() {
self.gatewayToken = ""
self.gatewayPassword = ""
self.gatewayCredentialFieldStableID = nil
self.pendingManualAuthOverride = nil
}
private func selectGatewayCredentialTarget(_ stableID: String, allowManualOverride: Bool) {
let instanceId = GatewaySettingsStore.currentInstanceID()
if self.gatewayCredentialFieldStableID != stableID {
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceId,
gatewayStableID: stableID)
self.gatewayCredentialFieldStableID = stableID
self.gatewayToken = credentials.token ?? ""
self.gatewayPassword = credentials.password ?? ""
}
guard allowManualOverride else {
self.pendingManualAuthOverride = nil
return
}
// Each attempt consumes the in-memory override. Reload durable bootstrap auth even
// when the endpoint fields did not change so retry never erases a one-time token.
self.pendingManualAuthOverride = GatewayConnectionController.ManualAuthOverride.persisted(
instanceId: instanceId,
targetStableID: stableID)
}
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)
self.connectingGatewayID = gateway.id
self.issue = .none
self.connectMessage = "Connecting to \(gateway.name)"
@@ -1118,6 +1416,12 @@ extension OnboardingWizardView {
}
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
let previousStableID = self.currentManualGatewayStableID
defer {
if previousStableID != self.currentManualGatewayStableID {
self.clearManualCredentialFields()
}
}
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
@@ -1151,27 +1455,53 @@ extension OnboardingWizardView {
self.invalidateSetupAttempt()
}
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
guard !host.isEmpty, let port = self.resolvedManualPort(host: host) else { return }
self.connectingGatewayID = "manual"
self.issue = .none
self.connectMessage = "Connecting to \(host)"
self.statusLine = "Connecting to \(host):\(self.manualPort)"
self.statusLine = "Connecting to \(host):\(port)"
defer { self.connectingGatewayID = nil }
await self.connectCurrentManualGateway(host: host, forceReconnect: false)
await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: false)
}
private func connectCurrentManualGateway(host: String, forceReconnect: Bool) async {
private func connectCurrentManualGateway(host: String, port: Int, forceReconnect: Bool) async {
let stableID = GatewayConnectionController.ManualAuthOverride.manualStableID(
host: host,
port: port)
self.selectGatewayCredentialTarget(stableID, allowManualOverride: true)
if self.appModel.activeGatewayConnectConfig?.effectiveStableID == stableID,
self.appModel.activeGatewayConnectConfig?.nodeOptions.allowStoredDeviceAuth == true
{
self.pendingManualAuthOverride = nil
}
let fieldsMatchTarget = self.gatewayCredentialFieldStableID == stableID
let pendingOverride = self.pendingManualAuthOverride?.targetStableID == stableID
? self.pendingManualAuthOverride
: nil
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: self.gatewayToken,
pendingOverride: self.pendingManualAuthOverride,
password: self.gatewayPassword)
self.pendingManualAuthOverride = nil
token: fieldsMatchTarget ? self.gatewayToken : nil,
pendingOverride: pendingOverride,
password: fieldsMatchTarget ? self.gatewayPassword : nil,
targetStableID: stableID)
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty, fieldsMatchTarget || pendingOverride != nil {
GatewaySettingsStore.saveGatewayCredentials(
token: authOverride?.token,
bootstrapToken: authOverride?.bootstrapToken,
password: authOverride?.password,
gatewayStableID: stableID,
suppressStoredDeviceAuth: authOverride?.suppressStoredDeviceAuth == true,
instanceId: instanceId)
}
await self.gatewayController.connectManual(
host: host,
port: self.manualPort,
port: port,
useTLS: self.manualTLS,
authOverride: authOverride,
forceReconnect: forceReconnect)
// The controller now owns this attempt's immutable override. A later retry must reload
// durable state so a spent bootstrap token cannot be resurrected from the live view.
self.pendingManualAuthOverride = nil
}
private func retryLastAttempt(silent: Bool = false) async {
@@ -1192,8 +1522,8 @@ extension OnboardingWizardView {
// a missing stored connection would silently do nothing. Manual
// retries must dial the current form input instead.
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
if !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 {
await self.connectCurrentManualGateway(host: host, forceReconnect: true)
if !host.isEmpty, let port = self.resolvedManualPort(host: host) {
await self.connectCurrentManualGateway(host: host, port: port, forceReconnect: true)
return
}
if !silent {
@@ -1215,13 +1545,14 @@ extension OnboardingWizardView {
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
self.gatewayToken = ""
self.gatewayPassword = ""
self.gatewayCredentialFieldStableID = nil
self.pendingManualAuthOverride = nil
self.connectingGatewayID = nil
self.connectMessage = nil
self.issue = .none
self.pairingRequestId = nil
self.statusLine = "Scan a fresh setup QR code from this gateway."
self.step = .connect
self.showQRScanner = true
self.openQRScannerFromOnboarding(status: "Scan a fresh setup QR code from this gateway.")
return
}
if problem.canTrustRotatedCertificate {
+122 -11
View File
@@ -2,9 +2,112 @@ import OpenClawKit
import SwiftUI
import VisionKit
enum QRScannerResult: Equatable {
case gatewayLink(GatewayConnectDeepLink)
case setupCode(String)
}
@MainActor
struct GatewayPendingTargetSuppression {
enum Owner: Equatable {
case qrScanner
case setupLink
}
private var value: (owner: Owner, lease: GatewayConnectionController.AutoConnectSuppressionLease)?
mutating func replace(
owner: Owner,
lease: GatewayConnectionController.AutoConnectSuppressionLease)
{
self.value = (owner, lease)
}
mutating func take(ifOwnedBy owner: Owner? = nil) -> GatewayConnectionController.AutoConnectSuppressionLease? {
guard let value = self.value else { return nil }
if let owner, value.owner != owner { return nil }
self.value = nil
return value.lease
}
mutating func resumeAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) {
guard let lease = self.take(ifOwnedBy: owner) else { return }
controller.resumeAutoConnect(after: lease)
}
mutating func releaseAutoConnect(_ owner: Owner? = nil, controller: GatewayConnectionController) {
guard let lease = self.take(ifOwnedBy: owner) else { return }
controller.releaseAutoConnectSuppression(after: lease)
}
}
@MainActor
final class QRScannerResultHandoff {
/// SwiftUI's onDismiss can precede VisionKit's AV capture teardown. Delay
/// pairing UI briefly so it cannot race the scanner's camera shutdown.
static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000
private let settlingNanoseconds: UInt64
private var pendingResult: QRScannerResult?
private var deliveryTask: Task<Void, Never>?
private var activeScanID: UInt64 = 0
init(settlingNanoseconds: UInt64 = QRScannerResultHandoff.defaultSettlingNanoseconds) {
self.settlingNanoseconds = settlingNanoseconds
}
@discardableResult
func beginScan() -> UInt64 {
self.cancel()
return self.activeScanID
}
func isActive(scanID: UInt64) -> Bool {
scanID == self.activeScanID && self.pendingResult == nil
}
@discardableResult
func queue(_ result: QRScannerResult, scanID: UInt64) -> Bool {
// Camera and Photos can finish together; the first valid result owns this scan.
guard self.isActive(scanID: scanID) else { return false }
self.pendingResult = result
return true
}
@discardableResult
func processAfterDismissal(
_ process: @escaping @MainActor (QRScannerResult) -> Void) -> Task<Void, Never>?
{
guard let result = self.pendingResult else {
self.cancel()
return nil
}
self.pendingResult = nil
self.activeScanID &+= 1
self.deliveryTask?.cancel()
let settlingNanoseconds = self.settlingNanoseconds
let task = Task { @MainActor in
do {
try await Task.sleep(nanoseconds: settlingNanoseconds)
} catch {
return
}
process(result)
}
self.deliveryTask = task
return task
}
func cancel() {
self.activeScanID &+= 1
self.deliveryTask?.cancel()
self.deliveryTask = nil
self.pendingResult = nil
}
}
struct QRScannerView: UIViewControllerRepresentable {
let onGatewayLink: (GatewayConnectDeepLink) -> Void
let onSetupCode: (String) -> Void
let onResult: (QRScannerResult) -> Void
let onError: (String) -> Void
let onDismiss: () -> Void
@@ -59,7 +162,11 @@ struct QRScannerView: UIViewControllerRepresentable {
}
}
func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
func dataScanner(
_ scanner: DataScannerViewController,
didAdd items: [RecognizedItem],
allItems _: [RecognizedItem])
{
guard !self.handled else { return }
for item in items {
guard case let .barcode(barcode) = item,
@@ -67,22 +174,26 @@ struct QRScannerView: UIViewControllerRepresentable {
else { continue }
if let link = GatewayConnectDeepLink.fromSetupInput(payload) {
self.handled = true
Task { @MainActor in
self.parent.onGatewayLink(link)
}
self.deliver(.gatewayLink(link), scanner: scanner)
return
}
if AppleReviewDemoMode.isSetupCode(payload) {
self.handled = true
Task { @MainActor in
self.parent.onSetupCode(payload)
}
self.deliver(.setupCode(payload), scanner: scanner)
return
}
}
}
private func deliver(_ result: QRScannerResult, scanner: DataScannerViewController) {
self.handled = true
// DataScannerViewController has no teardown-completion callback. Stop capture
// before owners dismiss the sheet and later present pairing UI.
scanner.stopScanning()
Task { @MainActor in
self.parent.onResult(result)
}
}
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
func dataScanner(
+79 -60
View File
@@ -43,13 +43,14 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
private var pendingAPNsDeviceToken: Data?
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
private var pendingExecApprovalRequestedPushIDs: [String] = []
private var pendingExecApprovalResolvedPushIDs: [String] = []
private var pendingExecApprovalRequestedPushes: [ExecApprovalNotificationPrompt] = []
private var pendingExecApprovalResolvedPushes: [ExecApprovalNotificationPrompt] = []
private var pendingOpenURLs: [URL] = []
weak var appModel: NodeAppModel? {
didSet {
guard let model = self.resolvedAppModel() else { return }
if let token = self.pendingAPNsDeviceToken {
guard let model = resolvedAppModel() else { return }
if let token = pendingAPNsDeviceToken {
self.pendingAPNsDeviceToken = nil
Task { @MainActor in
model.updateAPNsDeviceToken(token)
@@ -78,21 +79,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
}
}
if !self.pendingExecApprovalRequestedPushIDs.isEmpty {
let pending = self.pendingExecApprovalRequestedPushIDs
self.pendingExecApprovalRequestedPushIDs.removeAll()
if !self.pendingExecApprovalRequestedPushes.isEmpty {
let pending = self.pendingExecApprovalRequestedPushes
self.pendingExecApprovalRequestedPushes.removeAll()
Task { @MainActor in
for approvalId in pending {
_ = await model.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
for push in pending {
_ = await model.handleExecApprovalRequestedRemotePush(push)
}
}
}
if !self.pendingExecApprovalResolvedPushIDs.isEmpty {
let pending = self.pendingExecApprovalResolvedPushIDs
self.pendingExecApprovalResolvedPushIDs.removeAll()
if !self.pendingExecApprovalResolvedPushes.isEmpty {
let pending = self.pendingExecApprovalResolvedPushes
self.pendingExecApprovalResolvedPushes.removeAll()
Task { @MainActor in
for approvalId in pending {
await model.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
for push in pending {
_ = await model.handleExecApprovalResolvedRemotePush(push)
}
}
}
if !self.pendingOpenURLs.isEmpty {
let pending = self.pendingOpenURLs
self.pendingOpenURLs.removeAll()
Task { @MainActor in
for url in pending {
await self.handleOpenURL(url, model: model)
}
}
}
@@ -115,7 +125,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{
GatewayDiagnostics.log("app delegate: didFinishLaunching")
if self.appModel == nil {
@@ -131,6 +141,33 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
return true
}
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool
{
guard DeepLinkParser.parse(url) != nil else { return false }
guard let model = resolvedAppModel() else {
self.pendingOpenURLs.append(url)
return true
}
Task { @MainActor in
await self.handleOpenURL(url, model: model)
}
return true
}
func handleOpenURL(_ url: URL, model: NodeAppModel) async {
guard let route = DeepLinkParser.parse(url) else { return }
switch route {
case .agent, .dashboard:
await model.handleDeepLink(url: url)
case let .gateway(link):
model.stageGatewaySetupLink(link)
}
}
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard await Self.isNotificationAuthorizationAllowed() else { return }
@@ -149,8 +186,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if let appModel = self.resolvedAppModel() {
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if let appModel = resolvedAppModel() {
Task { @MainActor in
appModel.updateAPNsDeviceToken(deviceToken)
}
@@ -160,38 +197,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
self.pendingAPNsDeviceToken = deviceToken
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)")
}
func application(
_ application: UIApplication,
_: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
{
self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)")
Task { @MainActor in
let notificationCenter = LiveNotificationCenter()
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: userInfo,
notificationCenter: notificationCenter)
{
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
if let appModel = self.resolvedAppModel() {
await appModel.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
} else {
self.pendingExecApprovalResolvedPushIDs.append(approvalId)
}
if let push = ExecApprovalNotificationBridge.parseResolvedPush(userInfo: userInfo) {
if let appModel = self.resolvedAppModel() {
let handled = await appModel.handleExecApprovalResolvedRemotePush(push)
completionHandler(handled ? .newData : .noData)
} else {
self.pendingExecApprovalResolvedPushes.append(push)
completionHandler(.newData)
}
completionHandler(.newData)
return
}
guard let appModel = self.resolvedAppModel() else {
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
== ExecApprovalNotificationBridge.requestedKind,
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
{
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
if let push = ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) {
self.pendingExecApprovalRequestedPushes.append(push)
}
self.logger.info("APNs wake skipped: appModel unavailable")
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
@@ -340,7 +369,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
guard let appModel = self.resolvedAppModel() else {
guard let appModel = resolvedAppModel() else {
self.pendingWatchPromptActions.append(action)
return
}
@@ -354,7 +383,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
guard let appModel = self.resolvedAppModel() else {
guard let appModel = resolvedAppModel() else {
self.pendingExecApprovalPrompts.append(prompt)
return
}
@@ -364,7 +393,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
_: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
@@ -379,7 +408,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
_: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void)
{
@@ -451,18 +480,18 @@ enum WatchPromptNotificationBridge {
let center = UNUserNotificationCenter.current()
var categoryIdentifier = ""
if !displayedActions.isEmpty {
let categoryID = "\(self.categoryPrefix)\(invokeID)"
let categoryID = "\(categoryPrefix)\(invokeID)"
let category = UNNotificationCategory(
identifier: categoryID,
actions: self.categoryActions(displayedActions),
actions: categoryActions(displayedActions),
intentIdentifiers: [],
options: [])
await self.upsertNotificationCategory(category, center: center)
await upsertNotificationCategory(category, center: center)
categoryIdentifier = categoryID
}
var userInfo: [AnyHashable: Any] = [
self.typeKey: self.typeValue,
typeKey: typeValue,
]
if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty {
userInfo[self.promptIDKey] = promptId
@@ -510,7 +539,7 @@ enum WatchPromptNotificationBridge {
identifier: "watch.prompt.\(invokeID)",
content: content,
trigger: nil)
try? await self.addNotificationRequest(request, center: center)
try? await addNotificationRequest(request, center: center)
}
static func actionIDKey(index: Int) -> String {
@@ -552,7 +581,7 @@ enum WatchPromptNotificationBridge {
private static func isNotificationAuthorizationAllowed() async -> Bool {
let center = UNUserNotificationCenter.current()
let status = await self.notificationAuthorizationStatus(center: center)
let status = await notificationAuthorizationStatus(center: center)
return self.isAuthorizationStatusAllowed(status)
}
@@ -629,7 +658,7 @@ extension NodeAppModel {
note: "source=ios.notification",
sentAtMs: Int(Date().timeIntervalSince1970 * 1000),
transport: "ios.notification")
await self._bridgeConsumeMirroredWatchReply(event)
await _bridgeConsumeMirroredWatchReply(event)
}
}
@@ -693,7 +722,9 @@ struct OpenClawApp: App {
OpenClawType.refreshUIKitAppearance(in: Self.connectedWindows())
})
.onOpenURL { url in
Task { await self.handleOpenURL(url) }
// SwiftUI owns normal scene delivery; the delegate also queues URLs
// that arrive before the scene has installed its model.
Task { await self.appDelegate.handleOpenURL(url, model: self.appModel) }
}
.onChange(of: self.scenePhase) { _, newValue in
self.appModel.setScenePhase(newValue)
@@ -736,18 +767,6 @@ struct OpenClawApp: App {
}
extension OpenClawApp {
@MainActor
private func handleOpenURL(_ url: URL) async {
guard let route = DeepLinkParser.parse(url) else { return }
switch route {
case .agent, .dashboard:
await self.appModel.handleDeepLink(url: url)
case let .gateway(link):
self.appModel.stageGatewaySetupLink(link)
}
}
private static func installUncaughtExceptionLogger() {
NSLog("OpenClaw: installing uncaught exception handler")
NSSetUncaughtExceptionHandler { exception in
@@ -1,8 +1,9 @@
import Foundation
@preconcurrency import UserNotifications
struct ExecApprovalNotificationPrompt: Equatable {
struct ExecApprovalNotificationPrompt: Codable, Equatable, Hashable {
let approvalId: String
let gatewayDeviceId: String?
}
enum ExecApprovalNotificationBridge {
@@ -15,10 +16,10 @@ enum ExecApprovalNotificationBridge {
static func registerCategory(center: UNUserNotificationCenter = .current()) {
let category = UNNotificationCategory(
identifier: self.categoryIdentifier,
identifier: categoryIdentifier,
actions: [
UNNotificationAction(
identifier: self.reviewActionIdentifier,
identifier: reviewActionIdentifier,
title: "Review",
options: [.foreground]),
],
@@ -33,7 +34,7 @@ enum ExecApprovalNotificationBridge {
}
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
self.payloadKind(userInfo: userInfo) == self.requestedKind
self.parsePush(userInfo: userInfo, expectedKind: self.requestedKind) != nil
}
static func parsePrompt(
@@ -45,40 +46,43 @@ enum ExecApprovalNotificationBridge {
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)
return self.parseRequestedPush(userInfo: userInfo)
}
@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
}
static func parseRequestedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? {
self.parsePush(userInfo: userInfo, expectedKind: self.requestedKind)
}
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
return true
static func parseResolvedPush(userInfo: [AnyHashable: Any]) -> ExecApprovalNotificationPrompt? {
self.parsePush(userInfo: userInfo, expectedKind: self.resolvedKind)
}
@MainActor
static func removeNotifications(
forApprovalID approvalId: String,
notificationCenter: NotificationCentering) async
for push: ExecApprovalNotificationPrompt,
notificationCenter: NotificationCentering,
includingLegacyOwnerless: Bool = false) async
{
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
var pendingIdentifiers = [self.localRequestIdentifier(for: push)]
if includingLegacyOwnerless {
pendingIdentifiers.append("\(self.localRequestPrefix)\(push.approvalId)")
pendingIdentifiers.append(self.localRequestIdentifier(for: ExecApprovalNotificationPrompt(
approvalId: push.approvalId,
gatewayDeviceId: nil)))
}
var seenPendingIdentifiers = Set<String>()
pendingIdentifiers = pendingIdentifiers.filter { seenPendingIdentifiers.insert($0).inserted }
await notificationCenter.removePendingNotificationRequests(
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
withIdentifiers: pendingIdentifiers)
let delivered = await notificationCenter.deliveredNotifications()
let identifiers = delivered.compactMap { snapshot -> String? in
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
guard let requestedPush = self.parseRequestedPush(userInfo: snapshot.userInfo) else { return nil }
let matchesCurrentOwner = requestedPush == push
let matchesLegacyOwnerless = includingLegacyOwnerless &&
requestedPush.approvalId == push.approvalId &&
requestedPush.gatewayDeviceId == nil
guard matchesCurrentOwner || matchesLegacyOwnerless else { return nil }
return snapshot.identifier
}
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
@@ -90,8 +94,29 @@ enum ExecApprovalNotificationBridge {
return trimmed.isEmpty ? nil : trimmed
}
private static func localRequestIdentifier(for approvalId: String) -> String {
"\(self.localRequestPrefix)\(approvalId)"
private static func gatewayDeviceID(from userInfo: [AnyHashable: Any]) -> String? {
let raw = self.openClawPayload(userInfo: userInfo)?["gatewayDeviceId"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func parsePush(
userInfo: [AnyHashable: Any],
expectedKind: String) -> ExecApprovalNotificationPrompt?
{
guard self.payloadKind(userInfo: userInfo) == expectedKind,
let approvalId = approvalID(from: userInfo)
else {
return nil
}
return ExecApprovalNotificationPrompt(
approvalId: approvalId,
gatewayDeviceId: self.gatewayDeviceID(from: userInfo))
}
private static func localRequestIdentifier(for push: ExecApprovalNotificationPrompt) -> String {
let owner = push.gatewayDeviceId ?? "legacy"
return "\(self.localRequestPrefix)\(owner).\(push.approvalId)"
}
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
+25 -19
View File
@@ -202,6 +202,9 @@ struct RootTabs: View {
SettingsProTab(
initialRoute: self.selectedSettingsRoute,
acceptsGatewaySetupRequests: !self.showOnboarding &&
self.selectedTab == .settings &&
self.selectedSettingsRoute == .gateway,
onRouteChange: self.handleSettingsRouteChange,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
@@ -512,28 +515,29 @@ struct RootTabs: View {
directRoute: selectedSettingsRoute,
headerLeadingAction: self.sidebarHeaderLeadingAction,
ownsNavigationStack: false,
navigateToRoute: self.pushSidebarSettingsRoute,
onRouteChange: self.handleSettingsRouteChange,
navigateToRoute: pushSidebarSettingsRoute,
onRouteChange: handleSettingsRouteChange,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
} else {
SettingsProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
ownsNavigationStack: false,
navigateToRoute: self.pushSidebarSettingsRoute,
onRouteChange: self.handleSettingsRouteChange,
navigateToRoute: pushSidebarSettingsRoute,
onRouteChange: handleSettingsRouteChange,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
}
case .gateway:
SettingsProTab(
directRoute: self.selectedSettingsRoute ?? self.selectedSidebarDestination.settingsRoute ?? .gateway,
acceptsGatewaySetupRequests: !self.showOnboarding,
headerLeadingAction: self.sidebarHeaderLeadingAction,
ownsNavigationStack: false,
navigateToRoute: self.pushSidebarSettingsRoute,
onRouteChange: self.handleSettingsRouteChange,
navigateToRoute: pushSidebarSettingsRoute,
onRouteChange: handleSettingsRouteChange,
gatewaySetupRequest: self.gatewaySetupRequest,
onGatewaySetupRequestHandled: self.handleGatewaySetupRequest)
onGatewaySetupRequestHandled: handleGatewaySetupRequest)
}
}
@@ -677,7 +681,7 @@ struct RootTabs: View {
private var activeGatewayProblemToast: GatewayConnectionProblem? {
// Operator-scope auth/pairing failures can coexist with a connected node.
// The problem itself, not aggregate gateway status, owns toast visibility.
guard let problem = self.appModel.lastGatewayProblem,
guard let problem = appModel.lastGatewayProblem,
!self.isGatewayToastSwipeDismissed
else { return nil }
return problem
@@ -690,7 +694,7 @@ struct RootTabs: View {
private func gatewayProblemToast(_ problem: GatewayConnectionProblem) -> some View {
GatewayProblemBanner(
problem: problem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
primaryActionTitle: gatewayProblemPrimaryActionTitle(problem),
onPrimaryAction: {
self.handleGatewayProblemPrimaryAction(problem)
},
@@ -921,7 +925,7 @@ struct RootTabs: View {
.environment(self.voiceWake)
.environment(self.gatewayController)
}
.gatewayTrustPromptAlert()
.gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog(
suppressedApprovalID: self.activeExecApprovalPromptSuppressionID)
@@ -965,8 +969,8 @@ struct RootTabs: View {
}
private func makeHomeCanvasPayload() -> RootTabsHomeCanvasPayload {
let gatewayName = self.normalized(self.appModel.gatewayServerName)
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
let gatewayName = normalized(appModel.gatewayServerName)
let gatewayAddress = normalized(appModel.gatewayRemoteAddress)
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
let activeAgentID = self.resolveActiveAgentID()
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
@@ -1019,7 +1023,7 @@ struct RootTabs: View {
}
private func resolveActiveAgentID() -> String {
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
let selected = normalized(appModel.selectedAgentId) ?? ""
if !selected.isEmpty {
return selected
}
@@ -1027,7 +1031,7 @@ struct RootTabs: View {
}
private func resolveDefaultAgentID() -> String {
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
normalized(self.appModel.gatewayDefaultAgentId) ?? ""
}
private func homeCanvasAgents(activeAgentID: String) -> [RootTabsHomeCanvasAgentCard] {
@@ -1052,7 +1056,7 @@ struct RootTabs: View {
}
private func homeCanvasName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
normalized(agent.name) ?? agent.id
}
}
@@ -1123,7 +1127,7 @@ extension RootTabs {
}
private func requestPhoneControlNavigation(_ target: PhoneControlNavigationRequest.Target) {
let requestID = (self.phoneControlNavigationRequest?.id ?? 0) &+ 1
let requestID = (phoneControlNavigationRequest?.id ?? 0) &+ 1
self.phoneControlNavigationRequest = PhoneControlNavigationRequest(id: requestID, target: target)
}
@@ -1300,7 +1304,9 @@ extension RootTabs {
private func maybeOpenSettingsForGatewaySetup() {
let requestID = self.appModel.gatewaySetupRequestID
guard requestID != 0, requestID != self.gatewaySetupRequest?.id else { return }
guard let link = self.appModel.consumePendingGatewaySetupLink() else { return }
// The presented onboarding flow owns setup-link staging until it dismisses.
guard !self.showOnboarding else { return }
guard let link = appModel.consumePendingGatewaySetupLink() else { return }
self.showOnboarding = false
self.presentedSheet = nil
self.didAutoOpenSettings = true
@@ -93,9 +93,10 @@ enum WatchMessageKind: String, Codable, Equatable {
case quickReply
}
struct WatchExecApprovalResolveEvent: Equatable {
struct WatchExecApprovalResolveEvent: Codable, Equatable {
var replyId: String
var approvalId: String
var gatewayStableID: String?
var decision: OpenClawWatchExecApprovalDecision
var sentAtMs: Int?
var transport: String
@@ -32,6 +32,7 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
private let session: WCSession?
private let callbacksLock = NSLock()
private let snapshotContextLock = NSLock()
private var callbacks = WatchConnectivityTransportCallbacks()
override init() {
@@ -145,7 +146,12 @@ final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
}
do {
try session.updateApplicationContext(payload)
try self.snapshotContextLock.withLock {
let context = WatchMessagingPayloadCodec.encodeSnapshotApplicationContext(
payload,
merging: session.applicationContext)
try session.updateApplicationContext(context)
}
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
@@ -2,6 +2,11 @@ import Foundation
import OpenClawKit
enum WatchMessagingPayloadCodec {
private static let durableSnapshotTypes = [
OpenClawWatchPayloadType.appSnapshot.rawValue,
OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
]
static let completedChatReplyTextLimit = 4000
static func nowMs() -> Int {
@@ -68,6 +73,9 @@ enum WatchMessagingPayloadCodec {
"commandText": item.commandText,
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
]
if let gatewayStableID = nonEmpty(item.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let commandPreview = nonEmpty(item.commandPreview) {
payload["commandPreview"] = commandPreview
}
@@ -115,6 +123,9 @@ enum WatchMessagingPayloadCodec {
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
"approvalId": message.approvalId,
]
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let decision = message.decision {
payload["decision"] = decision.rawValue
}
@@ -135,6 +146,9 @@ enum WatchMessagingPayloadCodec {
"approvalId": message.approvalId,
"reason": message.reason.rawValue,
]
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let expiredAtMs = message.expiredAtMs {
payload["expiredAtMs"] = expiredAtMs
}
@@ -148,6 +162,9 @@ enum WatchMessagingPayloadCodec {
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
"approvals": message.approvals.map(self.encodeExecApprovalItem),
]
if let gatewayStableID = nonEmpty(message.gatewayStableID) {
payload["gatewayStableID"] = gatewayStableID
}
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
@@ -206,6 +223,31 @@ enum WatchMessagingPayloadCodec {
return payload
}
static func encodeSnapshotApplicationContext(
_ payload: [String: Any],
merging existingContext: [String: Any]) -> [String: Any]
{
guard let payloadType = payload["type"] as? String,
self.durableSnapshotTypes.contains(payloadType)
else {
return payload
}
// updateApplicationContext retains one dictionary. Nest both logical snapshots while
// keeping the newest one at the top level for older Watch app versions.
var context = payload
for snapshotType in self.durableSnapshotTypes {
if snapshotType == payloadType {
context[snapshotType] = payload
} else if let previous = existingContext[snapshotType] as? [String: Any] {
context[snapshotType] = previous
} else if existingContext["type"] as? String == snapshotType {
context[snapshotType] = existingContext
}
}
return context
}
static func encodeChatCompletionPayload(
_ message: OpenClawWatchChatCompletionMessage) -> [String: Any]
{
@@ -266,10 +308,12 @@ enum WatchMessagingPayloadCodec {
return nil
}
let replyId = self.nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let gatewayStableID = self.nonEmpty(payload["gatewayStableID"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalResolveEvent(
replyId: replyId,
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision,
sentAtMs: sentAtMs,
transport: transport)
@@ -23,7 +23,7 @@ struct TerminalHubScreen: View {
var body: some View {
let config = self.appModel.activeGatewayConnectConfig
let storedOperatorToken = config == nil ? nil : Self.storedOperatorToken()
let storedOperatorToken = Self.storedOperatorToken(config: config)
ZStack {
OpenClawProBackground()
if let url = Self.terminalURL(config: config) {
@@ -105,7 +105,9 @@ struct TerminalHubScreen: View {
/// (the same mechanism the macOS Dashboard window uses), so the token never
/// appears in the page URL, WebKit history, or gateway request logs.
static func terminalAuthUserScript(config: GatewayConnectConfig?) -> String? {
self.terminalAuthUserScript(config: config, storedOperatorToken: self.storedOperatorToken())
self.terminalAuthUserScript(
config: config,
storedOperatorToken: self.storedOperatorToken(config: config))
}
static func terminalAuthUserScript(
@@ -160,9 +162,17 @@ struct TerminalHubScreen: View {
return hasher.finalize()
}
private static func storedOperatorToken() -> String? {
private static func storedOperatorToken(config: GatewayConnectConfig?) -> String? {
guard let config else { return nil }
// Endpoint handoffs may explicitly suppress device-token reuse; every auth surface
// must honor that boundary or a stale token can override the supplied password.
guard config.nodeOptions.allowStoredDeviceAuth else { return nil }
let gatewayID = config.nodeOptions.deviceAuthGatewayID ?? config.effectiveStableID
let identity = DeviceIdentityStore.loadOrCreate()
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator")?
return DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)?
.token
}
@@ -201,7 +211,7 @@ private struct TerminalWebView: UIViewRepresentable {
// Ephemeral store: credentials arrive per load via the auth user
// script; nothing needs to persist across loads.
config.websiteDataStore = .nonPersistent()
if let authScript = self.authScript {
if let authScript {
config.userContentController.addUserScript(WKUserScript(
source: authScript,
injectionTime: .atDocumentStart,
+29 -11
View File
@@ -114,6 +114,7 @@ final class TalkModeManager: NSObject {
private var realtimeRelayStartInFlight = false
private var prefetchedRealtimeSession: TalkRealtimeClientSession?
private var realtimePrefetchTask: Task<Void, Never>?
private var realtimePrefetchGeneration: UInt64 = 0
private var lastHeard: Date?
private var lastTranscript: String = ""
@@ -335,6 +336,7 @@ final class TalkModeManager: NSObject {
if self.isEnabled, !self.isSpeaking {
self.statusText = "Offline"
}
self.realtimePrefetchGeneration &+= 1
self.realtimePrefetchTask?.cancel()
self.realtimePrefetchTask = nil
self.prefetchedRealtimeSession = nil
@@ -1383,7 +1385,10 @@ final class TalkModeManager: NSObject {
}
}
func prefetchRealtimeSessionIfReady(reason: String) async {
func prefetchRealtimeSessionIfReady(
reason: String,
shouldApply: @escaping @MainActor @Sendable () -> Bool = { true }) async
{
guard self.gatewayConnected,
self.realtimeSession == nil,
self.realtimeRelaySession == nil,
@@ -1395,15 +1400,27 @@ final class TalkModeManager: NSObject {
guard self.realtimePrefetchTask == nil else { return }
GatewayDiagnostics.log("talk.timeline realtime prefetch scheduled reason=\(reason)")
self.realtimePrefetchGeneration &+= 1
let prefetchGeneration = self.realtimePrefetchGeneration
self.realtimePrefetchTask = Task { @MainActor [weak self] in
guard let self else { return }
defer {
if self.realtimePrefetchGeneration == prefetchGeneration {
self.realtimePrefetchTask = nil
}
}
let startedAt = Self.nowSeconds()
do {
guard !Task.isCancelled, shouldApply(), let gateway = self.gateway else { return }
guard let route = await gateway.currentRoute() else { return }
guard !Task.isCancelled, shouldApply() else { return }
let session = try await self.createRealtimeClientSession(
gateway: gateway,
route: route,
provider: self.realtimeProvider,
model: self.realtimeModelId,
voice: self.realtimeVoiceId)
guard !Task.isCancelled else { return }
guard !Task.isCancelled, shouldApply() else { return }
self.prefetchedRealtimeSession = session
GatewayDiagnostics.log(
"talk.timeline realtime prefetch ready elapsedMs=\(Self.elapsedMs(since: startedAt)) "
@@ -1414,24 +1431,24 @@ final class TalkModeManager: NSObject {
"talk.timeline realtime prefetch failed elapsedMs=\(Self.elapsedMs(since: startedAt)) "
+ "error=\(error.localizedDescription)")
}
self.realtimePrefetchTask = nil
}
}
private func createRealtimeClientSession(
gateway: GatewayNodeSession,
route: GatewayNodeSessionRoute,
provider: String?,
model: String?,
voice: String?) async throws -> TalkRealtimeClientSession
{
guard let gateway else {
throw NSError(domain: "TalkMode", code: 8, userInfo: [
NSLocalizedDescriptionKey: "Gateway not connected",
])
}
let params = TalkRealtimeClientCreateParams(provider: provider, model: model, voice: voice)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await gateway.request(method: "talk.client.create", paramsJSON: json, timeoutSeconds: 12)
let res = try await gateway.request(
method: "talk.client.create",
paramsJSON: json,
timeoutSeconds: 12,
ifCurrentRoute: route)
return try JSONDecoder().decode(TalkRealtimeClientSession.self, from: res)
}
@@ -2826,12 +2843,12 @@ extension TalkModeManager {
+ "permission=\(self.gatewayTalkPermissionState.statusLabel)")
}
func reloadConfig() async {
func reloadConfig(shouldApply: @MainActor @Sendable () -> Bool = { true }) async {
guard let gateway else { return }
self.pcmFormatUnavailable = false
self.prefetchedRealtimeSession = nil
do {
guard let loaded = try await loadTalkConfig(from: gateway) else { return }
guard let loaded = try await loadTalkConfig(from: gateway), shouldApply() else { return }
let parsed = TalkModeGatewayConfigParser.parse(
config: loaded.config,
defaultProvider: Self.defaultTalkProvider,
@@ -2844,6 +2861,7 @@ extension TalkModeManager {
}
self.applyLoadedTalkConfig(parsed, redactedFallbackMissingScope: loaded.redactedFallbackMissingScope)
} catch {
guard shouldApply() else { return }
self.applyTalkConfigLoadFailure(error)
}
}
+28
View File
@@ -121,6 +121,16 @@ private func agentAction(
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseGatewayLinkRejectsInvalidPort() {
let url = URL(string: "openclaw://gateway?host=gateway.example.com&port=70000&tls=1")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseGatewayLinkRejectsMalformedPort() {
let url = URL(string: "openclaw://gateway?host=gateway.example.com&port=not-a-port&tls=1")!
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
@@ -138,6 +148,24 @@ private func agentAction(
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
}
@Test func parseGatewaySetupCodeRejectsInvalidPort() {
let payload = #"{"host":"gateway.example.com","port":70000,"tls":true}"#
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
}
@Test func invalidPortHasNoWebSocketURL() {
let link = GatewayConnectDeepLink(
host: "gateway.example.com",
port: -1,
tls: true,
bootstrapToken: nil,
token: nil,
password: nil)
#expect(link.websocketURL == nil)
#expect(!link.isValidEndpoint)
}
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
@@ -32,33 +32,39 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
}
@Suite(.serialized) struct ExecApprovalNotificationBridgeTests {
@Test func parsePromptMapsDefaultNotificationTap() {
@Test func `parse prompt maps default notification tap`() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
"gatewayDeviceId": "gateway-a",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
#expect(prompt == ExecApprovalNotificationPrompt(
approvalId: "approval-123",
gatewayDeviceId: "gateway-a"))
}
@Test func parsePromptMapsReviewAction() {
@Test func `parse prompt maps review action`() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-456",
"gatewayDeviceId": "gateway-b",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-456"))
#expect(prompt == ExecApprovalNotificationPrompt(
approvalId: "approval-456",
gatewayDeviceId: "gateway-b"))
}
@Test func parsePromptIgnoresUnexpectedActionIdentifiers() {
@Test func `parse prompt ignores unexpected action identifiers`() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: "openclaw.exec-approval.allow-once",
userInfo: [
@@ -71,7 +77,7 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
#expect(prompt == nil)
}
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
@Test @MainActor func `handle resolved push removes matching notifications`() async {
let center = MockNotificationCenter()
center.delivered = [
NotificationSnapshot(
@@ -80,6 +86,7 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
"gatewayDeviceId": "gateway-a",
],
]),
NotificationSnapshot(
@@ -87,22 +94,73 @@ private final class MockNotificationCenter: NotificationCentering, @unchecked Se
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-999",
"approvalId": "approval-123",
"gatewayDeviceId": "gateway-b",
],
]),
]
let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.resolvedKind,
"approvalId": "approval-123",
],
],
let push = ExecApprovalNotificationPrompt(
approvalId: "approval-123",
gatewayDeviceId: "gateway-a")
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: center)
#expect(handled)
#expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]])
#expect(center.pendingRemovedIdentifiers == [["exec.approval.gateway-a.approval-123"]])
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
}
@Test func `legacy ownerless approval pushes remain parseable for authenticated route validation`() {
let userInfo: [AnyHashable: Any] = [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-ownerless",
],
]
#expect(ExecApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) ==
ExecApprovalNotificationPrompt(
approvalId: "approval-ownerless",
gatewayDeviceId: nil))
#expect(ExecApprovalNotificationBridge.shouldPresentNotification(userInfo: userInfo))
}
@Test @MainActor func `validated cleanup removes legacy ownerless alerts but preserves other owners`() async {
let center = MockNotificationCenter()
center.delivered = [
NotificationSnapshot(
identifier: "legacy-ownerless",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-shared",
],
]),
NotificationSnapshot(
identifier: "other-owner",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-shared",
"gatewayDeviceId": "gateway-b",
],
]),
]
let push = ExecApprovalNotificationPrompt(
approvalId: "approval-shared",
gatewayDeviceId: "gateway-a")
await ExecApprovalNotificationBridge.removeNotifications(
for: push,
notificationCenter: center,
includingLegacyOwnerless: true)
#expect(center.pendingRemovedIdentifiers == [[
"exec.approval.gateway-a.approval-shared",
"exec.approval.approval-shared",
"exec.approval.legacy.approval-shared",
]])
#expect(center.deliveredRemovedIdentifiers == [["legacy-ownerless"]])
}
}
File diff suppressed because it is too large Load Diff
@@ -210,6 +210,29 @@ import Testing
#expect(appModel.gatewayStatusText == "Verify gateway TLS fingerprint")
}
@Test @MainActor func staleTrustAcceptanceReleasesAutoConnectSuppression() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
let stableID = "manual|\(host.lowercased())|\(port)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in .fingerprint("abc123") })
await controller.connectManual(host: host, port: port, useTLS: true)
_ = appModel.beginGatewayConnectAttempt()
await controller.acceptPendingTrustPrompt()
#expect(controller.pendingTrustPrompt == nil)
#expect(appModel.activeGatewayConnectConfig == nil)
#expect(!controller._test_isAutoConnectSuppressed())
}
@Test @MainActor func `manual first use TLS probe skips TLS when TCP is unreachable`() async {
let host = "gateway-\(UUID().uuidString).example.com"
let port = 18789
@@ -284,13 +307,21 @@ import Testing
GatewayTLSFingerprintProbeResult.fingerprint("abc123"),
.failure(.tlsHandshakeTimeout),
])
let resolverStarted = AsyncStream<Void>.makeStream()
let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream()
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
tcpReachabilityProbe: { _, _, _, _ in true },
tlsFingerprintProbe: { _ in tlsResults.withLock { $0.removeFirst() } },
serviceEndpointResolver: { _ in (host: discoveredHost, port: discoveredPort) })
serviceEndpointResolver: { _ in
resolverStarted.continuation.yield()
for await result in resolverResults.stream {
return result
}
return nil
})
await controller.connectManual(host: staleHost, port: stalePort, useTLS: true)
#expect(controller.pendingTrustPrompt?.fingerprintSha256 == "abc123")
@@ -301,14 +332,67 @@ import Testing
tailnetDns: discoveredHost,
gatewayPort: discoveredPort,
fingerprint: nil)
let message = await controller.connectWithDiagnostics(gateway)
var startedIterator = resolverStarted.stream.makeAsyncIterator()
let connectTask = Task { await controller.connectWithDiagnostics(gateway) }
_ = await startedIterator.next()
#expect(controller.pendingTrustPrompt == nil)
resolverResults.continuation.yield((host: discoveredHost, port: discoveredPort))
resolverResults.continuation.finish()
let message = await connectTask.value
#expect(message?.contains("TLS fingerprint verification timed out") == true)
#expect(message?.contains("\(discoveredHost):\(discoveredPort)") == true)
#expect(appModel.gatewayStatusText == message)
}
@Test @MainActor func targetSwitchCancelsSuspendedDiscoveryResolution() async {
let defaults = UserDefaults.standard
let previousInstanceID = defaults.string(forKey: "node.instanceId")
defer {
if let previousInstanceID {
defaults.set(previousInstanceID, forKey: "node.instanceId")
} else {
defaults.removeObject(forKey: "node.instanceId")
}
}
defaults.set("ios-test", forKey: "node.instanceId")
let resolverStarted = AsyncStream<Void>.makeStream()
let resolverResults = AsyncStream<(host: String, port: Int)>.makeStream()
let appModel = NodeAppModel()
defer { appModel.disconnectGateway() }
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
serviceEndpointResolver: { _ in
resolverStarted.continuation.yield()
for await result in resolverResults.stream {
return result
}
return nil
})
let stableID = "discovered|\(UUID().uuidString)"
defer { self.clearTLSFingerprint(stableID: stableID) }
let gateway = self.makeDiscoveredGateway(
stableID: stableID,
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
fingerprint: nil)
var startedIterator = resolverStarted.stream.makeAsyncIterator()
let connectTask = Task { await controller.connectWithDiagnostics(gateway) }
_ = await startedIterator.next()
controller.cancelPendingConnectionAttempts()
resolverResults.continuation.yield((host: "stale.gateway.invalid", port: 443))
resolverResults.continuation.finish()
let message = await connectTask.value
#expect(message == nil)
#expect(appModel.activeGatewayConnectConfig == nil)
#expect(controller.pendingTrustPrompt == nil)
}
@Test @MainActor func `clear all TLS fingerprints removes stored pins`() {
let stableID1 = "test|\(UUID().uuidString)"
let stableID2 = "test|\(UUID().uuidString)"
+276 -6
View File
@@ -1,4 +1,5 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@@ -94,7 +95,272 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
}
@Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
@Test func `credentials stay bound to their gateway`() {
let instanceID = "credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let firstGatewayID = "manual|first.example.com|443"
let secondGatewayID = "manual|second.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "first-token",
bootstrapToken: nil,
password: "first-password",
gatewayStableID: firstGatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
let first = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: firstGatewayID)
#expect(first.token == "first-token")
#expect(first.password == "first-password")
#expect(first.suppressStoredDeviceAuth)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID) == .empty)
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID) == .empty)
}
@Test func `shared tls certificate does not alias distinct routes`() {
let instanceID = "tls-owner-\(UUID().uuidString)"
let discoveredID = "bonjour|_openclaw._tcp|local|gateway-\(UUID().uuidString)"
let manualID = "manual|gateway-\(UUID().uuidString).local|443"
let fingerprint = "AA:BB:CC:DD"
defer {
GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID)
GatewayTLSStore.clearFingerprint(stableID: discoveredID)
GatewayTLSStore.clearFingerprint(stableID: manualID)
}
GatewaySettingsStore.saveGatewayCredentials(
token: "shared-token",
bootstrapToken: nil,
password: "shared-password",
gatewayStableID: discoveredID,
suppressStoredDeviceAuth: false,
instanceId: instanceID)
GatewayTLSStore.saveFingerprint(fingerprint, stableID: discoveredID)
GatewayTLSStore.saveFingerprint(fingerprint, stableID: manualID)
let manualCredentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: manualID)
#expect(manualCredentials == .empty)
#expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: discoveredID) == discoveredID)
#expect(GatewaySettingsStore.authenticationOwnerID(routeStableID: manualID) == manualID)
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID)?.gatewayStableID ==
discoveredID)
}
@Test func `ambiguous legacy credentials are discarded`() {
let instanceID = "legacy-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let firstGatewayID = "manual|first.example.com|443"
let secondGatewayID = "manual|second.example.com|443"
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest("legacy-token", instanceId: instanceID)
GatewaySettingsStore.discardUnscopedGatewayCredentials(instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: firstGatewayID) == .empty)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID) == .empty)
#expect(KeychainStore.loadString(
service: gatewayService,
account: "gateway-token.\(instanceID)") == nil)
#expect(KeychainStore.loadString(
service: gatewayService,
account: "gateway-credentials.\(instanceID)") == nil)
}
@Test func `proven relay migration does not overwrite a canonical credential bundle`() {
let instanceID = "relay-migration-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "current-token",
bootstrapToken: "current-bootstrap",
password: "current-password",
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
GatewaySettingsStore.saveLegacyGatewayTokenForMigrationTest(
"obsolete-token",
instanceId: instanceID)
#expect(GatewaySettingsStore.migrateProvenRelayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID,
token: "stale-relay-token",
password: "stale-relay-password"))
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(credentials.token == "current-token")
#expect(credentials.bootstrapToken == "current-bootstrap")
#expect(credentials.password == "current-password")
#expect(credentials.suppressStoredDeviceAuth)
#expect(KeychainStore.loadString(
service: gatewayService,
account: "gateway-token.\(instanceID)") == nil)
}
@Test func `proven relay credentials are not reimported after legacy cleanup`() {
let instanceID = "completed-relay-migration-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
#expect(GatewaySettingsStore.migrateProvenRelayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID,
token: "stale-relay-token",
password: "stale-relay-password"))
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID) == .empty)
}
@Test func `credentialless setup suppresses stored auth until handoff completes`() {
let instanceID = "credentialless-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: nil,
bootstrapToken: nil,
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
let pending = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(!pending.hasCredentials)
#expect(pending.suppressStoredDeviceAuth)
GatewaySettingsStore.completeGatewayCredentialHandoff(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID) == .empty)
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) == nil)
}
@Test func `bootstrap handoff clears bootstrap while enabling stored auth`() {
let instanceID = "bootstrap-handoff-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "shared-token",
bootstrapToken: "one-time-bootstrap",
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
#expect(GatewaySettingsStore.completeGatewayCredentialHandoff(
instanceId: instanceID,
gatewayStableID: gatewayID))
let completed = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(completed.token == "shared-token")
#expect(completed.bootstrapToken == nil)
#expect(!completed.suppressStoredDeviceAuth)
}
@Test func `field edits preserve pending bootstrap handoff for the same gateway`() {
let instanceID = "edited-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: nil,
bootstrapToken: "bootstrap-token",
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
GatewaySettingsStore.updateGatewayCredentials(
token: "edited-token",
password: "edited-password",
gatewayStableID: gatewayID,
instanceId: instanceID)
let credentials = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID)
#expect(credentials.token == "edited-token")
#expect(credentials.bootstrapToken == "bootstrap-token")
#expect(credentials.password == "edited-password")
#expect(credentials.suppressStoredDeviceAuth)
}
@Test func `field edits do not carry pending handoff to another gateway`() {
let instanceID = "switched-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let firstGatewayID = "manual|first.example.com|443"
let secondGatewayID = "manual|second.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "first-token",
bootstrapToken: "first-bootstrap-token",
password: "first-password",
gatewayStableID: firstGatewayID,
suppressStoredDeviceAuth: true,
instanceId: instanceID)
GatewaySettingsStore.updateGatewayCredentials(
token: "second-token",
password: nil,
gatewayStableID: secondGatewayID,
instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: firstGatewayID) == .empty)
let second = GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: secondGatewayID)
#expect(second.token == "second-token")
#expect(second.bootstrapToken == nil)
#expect(second.password == nil)
#expect(!second.suppressStoredDeviceAuth)
}
@Test func `clearing ordinary credentials removes their owner metadata`() {
let instanceID = "cleared-credential-owner-\(UUID().uuidString)"
defer { GatewaySettingsStore.deleteGatewayCredentials(instanceId: instanceID) }
let gatewayID = "manual|gateway.example.com|443"
GatewaySettingsStore.saveGatewayCredentials(
token: "one-time-token",
bootstrapToken: nil,
password: nil,
gatewayStableID: gatewayID,
suppressStoredDeviceAuth: false,
instanceId: instanceID)
GatewaySettingsStore.updateGatewayCredentials(
token: nil,
password: nil,
gatewayStableID: gatewayID,
instanceId: instanceID)
#expect(GatewaySettingsStore.loadGatewayCredentialMetadata(instanceId: instanceID) == nil)
#expect(GatewaySettingsStore.loadGatewayCredentials(
instanceId: instanceID,
gatewayStableID: gatewayID) == .empty)
}
@Test func `bootstrap copies defaults to keychain when missing`() {
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": "node-test",
@@ -115,7 +381,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
}
}
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
@Test func `bootstrap copies keychain to defaults when missing`() {
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": nil,
@@ -137,7 +403,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
}
}
@Test func lastGateway_manualRoundTrip() {
@Test func `last gateway manual round trip`() {
withLastGatewaySnapshot {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "example.com",
@@ -150,7 +416,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
}
}
@Test func lastGateway_discoveredOverwritesManual() {
@Test func `last gateway discovered overwrites manual`() {
withLastGatewaySnapshot {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "10.0.0.99",
@@ -164,7 +430,7 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
}
}
@Test func lastGateway_migratesFromUserDefaults() {
@Test func `last gateway migrates from user defaults`() {
withLastGatewaySnapshot {
// Clear Keychain entry and plant legacy UserDefaults values.
applyKeychain([lastGatewayKeychainEntry: nil])
@@ -177,7 +443,11 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
])
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
#expect(loaded == .manual(
host: "example.org",
port: 18789,
useTLS: false,
stableID: "manual|example.org|18789"))
// Legacy keys should be cleaned up after migration.
let defaults = UserDefaults.standard
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
import Foundation
import OpenClawKit
import Testing
import UIKit
@testable import OpenClaw
@Suite(.serialized) struct OpenClawAppDelegateTests {
@@ -31,4 +33,57 @@ import Testing
#expect(delegate._test_wakeRefreshTaskIdentifier() == "\(bundleIdentifier).bgrefresh")
}
@Test @MainActor func `stages a gateway URL when the model is ready`() async throws {
OpenClawAppModelRegistry.appModel = nil
defer { OpenClawAppModelRegistry.appModel = nil }
let model = NodeAppModel()
let delegate = OpenClawAppDelegate()
delegate.appModel = model
let url = try #require(URL(
string: "openclaw://gateway?host=gateway.example.com&port=443&tls=1&token=tok"))
#expect(delegate.application(UIApplication.shared, open: url))
let link = await Self.waitForGatewaySetup(in: model)
#expect(link?.host == "gateway.example.com")
#expect(link?.port == 443)
#expect(link?.tls == true)
#expect(link?.token == "tok")
}
@Test @MainActor func `replays a gateway URL received before the model is ready`() async throws {
OpenClawAppModelRegistry.appModel = nil
defer { OpenClawAppModelRegistry.appModel = nil }
let delegate = OpenClawAppDelegate()
let url = try #require(URL(
string: "openclaw://gateway?host=gateway.example.com&port=443&tls=1&token=tok"))
#expect(delegate.application(UIApplication.shared, open: url))
let model = NodeAppModel()
delegate.appModel = model
let link = await Self.waitForGatewaySetup(in: model)
#expect(link?.host == "gateway.example.com")
#expect(link?.token == "tok")
}
@Test @MainActor func `rejects an invalid URL`() throws {
let delegate = OpenClawAppDelegate()
let url = try #require(URL(string: "https://example.com/gateway"))
#expect(!delegate.application(UIApplication.shared, open: url))
}
@MainActor
private static func waitForGatewaySetup(in model: NodeAppModel) async -> GatewayConnectDeepLink? {
for _ in 0..<20 {
if model.gatewaySetupRequestID > 0 {
return model.consumePendingGatewaySetupLink()
}
await Task.yield()
}
return nil
}
}
@@ -0,0 +1,141 @@
import OpenClawKit
import Testing
@testable import OpenClaw
@MainActor
struct QRScannerResultHandoffTests {
@Test func `queued result is delivered once after dismissal`() async throws {
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
var deliveredResult: QRScannerResult?
let scanID = handoff.beginScan()
handoff.queue(.setupCode("review-demo"), scanID: scanID)
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
await task.value
#expect(deliveredResult == .setupCode("review-demo"))
#expect(handoff.processAfterDismissal { _ in } == nil)
}
@Test func `cancel prevents queued delivery`() async throws {
let handoff = QRScannerResultHandoff(settlingNanoseconds: 1_000_000_000)
var deliveredResult: QRScannerResult?
let scanID = handoff.beginScan()
handoff.queue(.setupCode("review-demo"), scanID: scanID)
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
handoff.cancel()
await task.value
#expect(deliveredResult == nil)
}
@Test func `beginning another scan clears stale result`() {
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
let staleScanID = handoff.beginScan()
handoff.queue(.setupCode("stale"), scanID: staleScanID)
handoff.beginScan()
#expect(handoff.processAfterDismissal { _ in } == nil)
}
@Test func `late result from cancelled scan cannot replace newer input`() async throws {
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
let staleScanID = handoff.beginScan()
handoff.cancel()
let currentScanID = handoff.beginScan()
var deliveredResult: QRScannerResult?
#expect(!handoff.queue(.setupCode("stale"), scanID: staleScanID))
#expect(handoff.queue(.setupCode("current"), scanID: currentScanID))
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
await task.value
#expect(deliveredResult == .setupCode("current"))
}
@Test func `first producer claims the active scan`() async throws {
let handoff = QRScannerResultHandoff(settlingNanoseconds: 0)
let scanID = handoff.beginScan()
var deliveredResult: QRScannerResult?
#expect(handoff.queue(.setupCode("camera"), scanID: scanID))
#expect(!handoff.isActive(scanID: scanID))
#expect(!handoff.queue(.setupCode("photo"), scanID: scanID))
let task = try #require(handoff.processAfterDismissal { deliveredResult = $0 })
await task.value
#expect(deliveredResult == .setupCode("camera"))
}
}
struct GatewaySetupLinkStagingTests {
private static func link() -> GatewayConnectDeepLink {
GatewayConnectDeepLink(
host: "gateway.example.com",
port: 443,
tls: true,
bootstrapToken: "bootstrap",
token: "token",
password: "password")
}
@Test func `staged link is consumed once`() {
var staging = GatewaySetupLinkStaging()
let link = Self.link()
staging.stage(link)
#expect(staging.take() == link)
#expect(staging.take() == nil)
}
@Test func `cancel discards staged credentials`() {
var staging = GatewaySetupLinkStaging()
staging.stage(Self.link())
let cancelled = staging.cancel()
#expect(cancelled)
#expect(staging.link == nil)
let cancelledAgain = staging.cancel()
#expect(!cancelledAgain)
}
@Test func `new setup link replaces the pending candidate`() {
var staging = GatewaySetupLinkStaging()
let replacement = GatewayConnectDeepLink(
host: "replacement.example.com",
port: 8443,
tls: true,
bootstrapToken: nil,
token: nil,
password: nil)
staging.stage(Self.link())
staging.stage(replacement)
#expect(staging.take() == replacement)
}
}
@MainActor
struct GatewayPendingTargetSuppressionTests {
@Test func `new setup target cannot be released by stale scanner dismissal`() {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
var pending = GatewayPendingTargetSuppression()
let scannerLease = controller.cancelPendingConnectionAttempts()
pending.replace(owner: .qrScanner, lease: scannerLease)
let setupLease = controller.cancelPendingConnectionAttempts()
pending.replace(owner: .setupLink, lease: setupLease)
#expect(pending.take(ifOwnedBy: .qrScanner) == nil)
let activeLease = pending.take(ifOwnedBy: .setupLink)
#expect(activeLease != nil)
if let activeLease {
controller.releaseAutoConnectSuppression(after: activeLease)
}
#expect(!controller._test_isAutoConnectSuppressed())
}
}
+392 -8
View File
@@ -705,7 +705,7 @@ struct RootTabsSourceGuardTests {
#expect(rootSource.contains("case .settings:"))
#expect(rootSource
.matches(
of: /case \.settings:[\s\S]*?SettingsProTab\([\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,[\s\S]*?ownsNavigationStack: false[\s\S]*?onRouteChange: self\.handleSettingsRouteChange/)
of: /case \.settings:[\s\S]*?SettingsProTab\([\s\S]*?headerLeadingAction: self\.sidebarHeaderLeadingAction,[\s\S]*?ownsNavigationStack: false[\s\S]*?onRouteChange: handleSettingsRouteChange/)
.count >= 1)
#expect(rootSource
.contains(
@@ -727,8 +727,8 @@ struct RootTabsSourceGuardTests {
#expect(rootSource.contains("self.selectedSettingsRoute = nil"))
#expect(rootSource.contains("self.selectedSidebarDestination = .settings"))
#expect(rootSource.contains("self.suppressedExecApprovalPromptIDForNotificationSettings = approvalId"))
#expect(rootSource.contains("onRouteChange: self.handleSettingsRouteChange"))
#expect(rootSource.contains("navigateToRoute: self.pushSidebarSettingsRoute"))
#expect(rootSource.contains("onRouteChange: handleSettingsRouteChange"))
#expect(rootSource.contains("navigateToRoute: pushSidebarSettingsRoute"))
#expect(rootSource.contains("private func pushSidebarSettingsRoute(_ route: SettingsRoute)"))
#expect(rootSource.contains("self.sidebarNavigationPath.append(route)"))
#expect(settingsTabSource.contains("let navigateToRoute: ((SettingsRoute) -> Void)?"))
@@ -768,12 +768,95 @@ struct RootTabsSourceGuardTests {
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let scannerSource = try String(contentsOf: Self.qrScannerSourceURL(), encoding: .utf8)
let settingsScannerSheet = try Self.extract(
settingsSource,
from: "isPresented: self.$showQRScanner,",
to: ".sheet(isPresented: self.$showNotificationRelayDisclosure)")
let settingsOnDismiss = try #require(settingsScannerSheet.range(of: "onDismiss: {"))
let settingsProcessing = try #require(settingsScannerSheet.range(of: "self.processQueuedScannerResult()"))
let settingsContent = try #require(settingsScannerSheet.range(of: "content: {"))
let settingsPendingSetupHandler = try Self.extract(
actionsSource,
from: "func applyGatewaySetupLink(_ link: GatewayConnectDeepLink)",
to: "@discardableResult\n func applySetupCode(attemptID: UUID)")
let settingsScannerCancel = try #require(
settingsPendingSetupHandler.range(of: "self.scannerResultHandoff.cancel()"))
let settingsSetupStaging = try #require(
settingsPendingSetupHandler.range(of: "self.stagedGatewaySetupLink = link"))
let scannerDelivery = try Self.extract(
scannerSource,
from: "private func deliver(_ result: QRScannerResult",
to: "func dataScanner(_: DataScannerViewController, didRemove")
let stopScanning = try #require(scannerDelivery.range(of: "scanner.stopScanning()"))
let deliverResult = try #require(scannerDelivery.range(of: "self.parent.onResult(result)"))
#expect(scannerSource.contains("static let defaultSettlingNanoseconds: UInt64 = 1_200_000_000"))
let activeProblemToast = try Self.extract(
rootSource,
from: "private var activeGatewayProblemToast: GatewayConnectionProblem?",
to: "private var gatewayToastAnimation: Animation?")
let gatewaySetupSource = try Self.extract(
rootSource,
from: "private func maybeOpenSettingsForGatewaySetup()",
to: "private func maybeRequestLocalNetworkAccess")
let consumedGatewaySetup = try #require(
gatewaySetupSource.range(of: "appModel.consumePendingGatewaySetupLink()"))
let onboardingSetupOwnerGuard = try #require(
gatewaySetupSource.range(of: "guard !self.showOnboarding else { return }"))
let deliveredGatewaySetup = try #require(
gatewaySetupSource.range(of: "self.gatewaySetupRequest = GatewaySetupRequest"))
let pendingSetupHandler = try Self.extract(
onboardingSource,
from: "private func applyPendingGatewaySetupLinkIfNeeded()",
to: "private func connectStagedGatewaySetupLink()")
let stagedSetupConnect = try Self.extract(
onboardingSource,
from: "private func connectStagedGatewaySetupLink()",
to: "private func clearStagedGatewaySetupLink()")
let stagedValidation = try #require(stagedSetupConnect.range(of: "guard link.isValidEndpoint"))
let stagedConsumption = try #require(stagedSetupConnect.range(of: "self.setupLinkStaging.take()"))
let stagedReset = try #require(
stagedSetupConnect.range(of: "await self.appModel.resetGatewaySessionsForTargetSwitch()"))
let backgroundReconnect = try Self.extract(
modelSource,
from: "private func performBackgroundAliveBeaconIfNeeded(",
to: "private func publishBackgroundAliveBeacon(")
let disconnectGateway = try Self.extract(
modelSource,
from: "func disconnectGateway()",
to: "private func disableGatewayAutoReconnect()")
let operatorGatewayLoop = try Self.extract(
modelSource,
from: "private func startOperatorGatewayLoop(",
to: "private func startNodeGatewayLoop(")
let nodeGatewayLoop = try Self.extract(
modelSource,
from: "private func startNodeGatewayLoop(",
to: "private func makeOperatorConnectOptions(")
let wakeWordRefresh = try Self.extract(
modelSource,
from: "private func refreshWakeWordsFromGateway(",
to: "private func isGatewayHealthMonitorDisabled()")
let onboardingGatewayLink = try Self.extract(
onboardingSource,
from: "private func applyGatewayLink(",
to: "private func handleScannedSetupCode(")
let settingsGatewayLink = try Self.extract(
actionsSource,
from: "func applyGatewayLink(",
to: "func openGatewayQRScanner()")
let onboardingManualConnect = try Self.extract(
onboardingSource,
from: "private func connectCurrentManualGateway(",
to: "private func retryLastAttempt(")
let settingsManualConnect = try Self.extract(
actionsSource,
from: "func connectManual(setupAttemptID: UUID? = nil) async",
to: "func preflightGateway(host: String)")
#expect(sectionsSource.contains("var gatewayDestination: some View"))
#expect(sectionsSource.contains("self.gatewayActions"))
@@ -798,7 +881,7 @@ struct RootTabsSourceGuardTests {
#expect(rootSource.contains(".gesture(self.gatewayToastSwipeGesture)"))
// Operator auth/pairing problems can coexist with a connected node, so the
// root's only remediation surface must not depend on aggregate status.
#expect(activeProblemToast.contains("self.appModel.lastGatewayProblem"))
#expect(activeProblemToast.contains("appModel.lastGatewayProblem"))
#expect(!activeProblemToast.contains("gatewayStatus"))
// Every problem report re-surfaces a swiped-away toast or shakes the
// visible one; value equality alone must not keep the toast hidden.
@@ -818,6 +901,10 @@ struct RootTabsSourceGuardTests {
#expect(settingsSource.contains("self.resetOnboarding()"))
#expect(settingsSource.contains(".onChange(of: self.onboardingRequestID)"))
#expect(settingsSource.contains("self.syncAfterOnboardingReset()"))
#expect(settingsSource.contains("let acceptsGatewaySetupRequests: Bool"))
#expect(settingsSource.contains("guard self.acceptsGatewaySetupRequests else { return }"))
#expect(settingsSource.contains(".onChange(of: self.acceptsGatewaySetupRequests)"))
#expect(rootSource.matches(of: /acceptsGatewaySetupRequests: !self\.showOnboarding/).count == 2)
#expect(actionsSource.contains("func syncAfterOnboardingReset()"))
#expect(actionsSource.contains("self.pendingManualAuthOverride = nil"))
// The root toast is the only gateway problem surface outside covers, so it
@@ -829,11 +916,223 @@ struct RootTabsSourceGuardTests {
#expect(rootSource.contains("await self.gatewayController.connectLastKnown()"))
#expect(rootSource.contains("GatewayProblemDetailsSheet("))
#expect(onboardingSetupOwnerGuard.lowerBound < consumedGatewaySetup.lowerBound)
#expect(consumedGatewaySetup.lowerBound < deliveredGatewaySetup.lowerBound)
#expect(settingsSource.contains("QRScannerView("))
#expect(settingsOnDismiss.lowerBound < settingsProcessing.lowerBound)
#expect(settingsProcessing.lowerBound < settingsContent.lowerBound)
#expect(settingsPendingSetupHandler.contains("self.showQRScanner = false"))
#expect(settingsScannerCancel.lowerBound < settingsSetupStaging.lowerBound)
#expect(settingsPendingSetupHandler.contains(
"self.gatewayController.cancelPendingConnectionAttempts()"))
#expect(!settingsSource.contains(".onChange(of: self.showQRScanner)"))
#expect(actionsSource.contains("case let .gatewayLink(link):"))
#expect(actionsSource.contains("case let .setupCode(code):"))
#expect(stopScanning.lowerBound < deliverResult.lowerBound)
#expect(trustSource.contains("Trust this gateway?"))
#expect(trustSource.contains("Trust and connect"))
#expect(trustSource.contains("let isEnabled: Bool"))
#expect(rootSource.contains(".gatewayTrustPromptAlert(isEnabled: !self.showOnboarding)"))
#expect(onboardingSource.contains(".gatewayTrustPromptAlert()"))
#expect(onboardingSource.contains("self.applyPendingGatewaySetupLinkIfNeeded()"))
#expect(onboardingSource.contains(".onChange(of: self.appModel.gatewaySetupRequestID)"))
#expect(onboardingSource.contains("self.appModel.consumePendingGatewaySetupLink()"))
#expect(onboardingSource.contains("self.scannerResultHandoff.cancel()"))
#expect(!onboardingSource.contains("pendingScannerResult"))
#expect(onboardingSource.contains("self.setupLinkStaging.stage(link)"))
#expect(pendingSetupHandler.contains("self.gatewayController.cancelPendingConnectionAttempts()"))
#expect(pendingSetupHandler.contains("if self.selectedMode == nil"))
#expect(onboardingSource.contains("Tap Connect to apply."))
#expect(onboardingSource.contains("self.connectStagedGatewaySetupLink()"))
#expect(onboardingSource.contains("Credentials are applied only after you tap Connect."))
#expect(onboardingSource.contains("Plaintext (local network)"))
#expect(onboardingSource.contains("self.statusLine = message"))
#expect(!pendingSetupHandler.contains("self.manualHost ="))
#expect(!pendingSetupHandler.contains("self.manualPort ="))
#expect(!pendingSetupHandler.contains("self.manualTLS ="))
#expect(!pendingSetupHandler.contains("self.applyGatewayLink(link)"))
#expect(!pendingSetupHandler.contains("self.handleScannedLink(link)"))
#expect(!pendingSetupHandler.contains("self.connectManual()"))
#expect(stagedValidation.lowerBound < stagedConsumption.lowerBound)
#expect(stagedReset.lowerBound < stagedConsumption.lowerBound)
#expect(!stagedSetupConnect.contains("self.appModel.disconnectGateway()"))
#expect(stagedSetupConnect.contains(
"self.applyGatewayLink(link, disconnectExistingGatewayForBootstrap: false)"))
#expect(stagedSetupConnect.contains("guard self.connectingGatewayID == nil else { return }"))
#expect(onboardingSource.contains("self.setupLinkStaging.link == nil else { return }"))
#expect(onboardingGatewayLink.contains("self.gatewayToken = setupAuth.token"))
#expect(onboardingGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
#expect(settingsGatewayLink.contains("self.gatewayToken = setupAuth.token"))
#expect(settingsGatewayLink.contains("self.gatewayPassword = setupAuth.password"))
#expect(onboardingManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
#expect(onboardingManualConnect.contains("self.pendingManualAuthOverride = nil"))
#expect(onboardingManualConnect.contains("targetStableID: stableID"))
#expect(settingsManualConnect.contains("nodeOptions.allowStoredDeviceAuth == true"))
#expect(settingsManualConnect.contains("self.pendingManualAuthOverride = nil"))
#expect(settingsManualConnect.contains("targetStableID: stableID"))
#expect(!controllerSource.contains("shouldApplyTokenField"))
#expect(!controllerSource.contains("shouldApplyPasswordField"))
#expect(controllerSource.contains("allowStoredDeviceAuth: !suppressStoredDeviceAuth"))
#expect(controllerSource.contains(
"deviceAuthGatewayID: GatewaySettingsStore.authenticationOwnerID("))
#expect(controllerSource.contains("DeviceAuthStore.migrateUnscopedToken("))
#expect(controllerSource.contains("DeviceAuthStore.discardUnscopedTokens("))
#expect(onboardingSource.contains(
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
#expect(actionsSource.contains(
"self.selectGatewayCredentialTarget(gateway.stableID, allowManualOverride: false)"))
#expect(onboardingSource.contains(
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
#expect(actionsSource.contains(
"self.gatewayCredentialFieldStableID ?? self.currentManualGatewayStableID"))
#expect(disconnectGateway.contains("self.beginGatewaySessionReset(chainingAfterExisting: true)"))
#expect(!disconnectGateway.contains("Task {"))
#expect(modelSource.contains(
"private func isCurrentGatewayRoute(generation: UInt64, stableID: String) -> Bool"))
#expect(modelSource.matches(
of: /self\.isCurrentGatewayRoute\(generation: routeGeneration, stableID: stableID\)/).count >= 2)
#expect(operatorGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"operator_loop\")"))
#expect(nodeGatewayLoop.contains("gatewayReconnectLoopDelay(source: \"node_loop\")"))
#expect(modelSource.contains("refreshWakeWordsFromGateway(shouldApply: shouldContinue)"))
#expect(wakeWordRefresh.matches(of: /guard shouldApply\(\) else \{ return \}/).count >= 2)
#expect(modelSource.contains("if !self.gatewayAutoReconnectEnabled || self.gatewayPairingPaused"))
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
#expect(controllerSource.contains("allowAutoReconnect: false"))
#expect(controllerSource.contains("guard allowAutoReconnect else { return }"))
#expect(controllerSource.contains("guard self.autoConnectSuppressionGeneration == nil else { return }"))
#expect(backgroundReconnect.contains("let generation = self.gatewayConnectGeneration"))
#expect(backgroundReconnect.contains("await self.resetGatewaySessionsForForcedReconnect()"))
#expect(backgroundReconnect.contains("expectedGeneration: generation"))
#expect(modelSource.contains("expectedGeneration: UInt64)"))
#expect(!modelSource.contains("expectedGeneration: UInt64?"))
}
@Test func `gateway credential fields update before endpoint persistence is available`() throws {
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
for source in [onboardingSource, settingsSource] {
let tokenSetter = try Self.extract(
source,
from: "func persistGatewayToken(_ value: String)",
to: "func persistGatewayPassword(_ value: String)")
let passwordSetter = try Self.extract(
source,
from: "func persistGatewayPassword(_ value: String)",
to: "func clearManualCredentialFields()")
let tokenAssignment = try #require(tokenSetter.range(of: "self.gatewayToken = value"))
let tokenEndpointGuard = try #require(
tokenSetter.range(of: "let stableID = self.gatewayCredentialTargetStableID"))
let passwordAssignment = try #require(passwordSetter.range(of: "self.gatewayPassword = value"))
let passwordEndpointGuard = try #require(
passwordSetter.range(of: "let stableID = self.gatewayCredentialTargetStableID"))
#expect(tokenAssignment.lowerBound < tokenEndpointGuard.lowerBound)
#expect(passwordAssignment.lowerBound < passwordEndpointGuard.lowerBound)
}
}
@Test func `onboarding mode defaults clear credentials after endpoint changes`() throws {
let source = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let modeDefaults = try Self.extract(
source,
from: "private func applyModeDefaults(_ mode: OnboardingConnectionMode)",
to: "private func gatewayHasResolvableHost")
#expect(modeDefaults.contains("let previousStableID = self.currentManualGatewayStableID"))
#expect(modeDefaults.contains("previousStableID != self.currentManualGatewayStableID"))
#expect(modeDefaults.contains("self.clearManualCredentialFields()"))
}
@Test func `watch snapshot bundle applies owner before approvals and clears old chat`() throws {
let receiverSource = try String(contentsOf: Self.watchConnectivityReceiverSourceURL(), encoding: .utf8)
let storeSource = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8)
let consumePayload = try Self.extract(
receiverSource,
from: "private func consumeIncomingPayload(_ payload: [String: Any], transport: String)",
to: "}\n}")
let appSnapshotConsume = try #require(
consumePayload.range(of: "self.store.consume(appSnapshot: appSnapshot)"))
let approvalSnapshotConsume = try #require(
consumePayload.range(of: "self.store.consume(execApprovalSnapshot: execApprovalSnapshot"))
let consumeAppSnapshot = try Self.extract(
storeSource,
from: "func consume(appSnapshot message: WatchAppSnapshotMessage)",
to: "func markAppSnapshotRequestStarted()")
#expect(appSnapshotConsume.lowerBound < approvalSnapshotConsume.lowerBound)
#expect(consumeAppSnapshot.contains("if hasExistingAppSnapshot, previousGatewayID == nextGatewayID"))
let ownerMatchedMerge = try Self.extract(
consumeAppSnapshot,
from: "if hasExistingAppSnapshot, previousGatewayID == nextGatewayID",
to: "self.appSnapshot = merged")
#expect(ownerMatchedMerge.contains("merged.chatItems = self.appSnapshot?.chatItems"))
#expect(ownerMatchedMerge.contains("merged.chatStatusText = self.appSnapshot?.chatStatusText"))
}
@Test func `watch generic prompts wait for the active gateway owner`() throws {
let receiverSource = try String(contentsOf: Self.watchConnectivityReceiverSourceURL(), encoding: .utf8)
let source = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8)
let consumeMessage = try Self.extract(
source,
from: "func consume(message: WatchNotifyMessage, transport: String)",
to: "func consume(\n execApprovalPrompt")
let consumeAppSnapshot = try Self.extract(
source,
from: "func consume(appSnapshot message: WatchAppSnapshotMessage)",
to: "func markAppSnapshotRequestStarted()")
let replay = try Self.extract(
source,
from: "func replayDeferredGatewayPayloads()",
to: "private func clearMessagePrompt()")
let routeGatewayPayload = try Self.extract(
source,
from: "private func routeGatewayPayload(_ payload: DeferredGatewayPayload)",
to: "private func acceptsGatewayOwner")
let acceptsGatewayOwner = try Self.extract(
source,
from: "private func acceptsGatewayOwner(_ gatewayStableID: String?)",
to: "func replayDeferredGatewayPayloads()")
#expect(consumeMessage.contains("self.routeGatewayPayload(.notification"))
#expect(consumeAppSnapshot.contains("self.clearMessagePrompt()"))
#expect(consumeAppSnapshot.contains("if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID"))
#expect(source.contains("private var deferredGatewayPayloads: [DeferredGatewayPayload]"))
#expect(routeGatewayPayload.contains("guard let activeSnapshot = appSnapshot else { return true }"))
#expect(acceptsGatewayOwner.contains("guard let activeSnapshot = appSnapshot else { return true }"))
#expect(acceptsGatewayOwner.contains("else { return false }"))
#expect(replay.contains("WatchDeferredPayloadOrdering.indicesOldestFirst"))
#expect(replay.contains("WatchDeferredPayloadOrdering.isExpired"))
#expect(replay.contains("WatchDeferredPayloadOrdering.isNewerThanSnapshot"))
#expect(replay.contains("WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot"))
#expect(replay.contains("case let .notification(message, transport):"))
#expect(replay.contains("approvalSnapshotGatewayID == activeGatewayID"))
#expect(replay.contains("payload.isFullyRepresentedByExecApprovalSnapshot"))
#expect(replay.contains("let approval = payload.approvalPrompt"))
#expect(source.contains("if hasSameSnapshotOwner"))
#expect(source.contains("if let sentAtMs = message.sentAtMs"))
#expect(receiverSource.contains("self.store.replayDeferredGatewayPayloads()"))
}
@Test func `watch approval notifications include their gateway owner`() throws {
let source = try String(contentsOf: Self.watchInboxStoreSourceURL(), encoding: .utf8)
let identifier = try Self.extract(
source,
from: "private static func execApprovalNotificationIdentifier(",
to: "private func pruneExpiredExecApprovals")
let routeChange = try Self.extract(
source,
from: "func consume(appSnapshot message: WatchAppSnapshotMessage)",
to: "func markAppSnapshotRequestStarted()")
#expect(identifier.contains("gatewayStableID.utf8.count"))
#expect(identifier.contains("gatewayStableID)\\(approvalID)"))
#expect(routeChange.contains("removeExecApprovalNotifications(approvals: invalidatedApprovals)"))
#expect(!source.contains("identifier: \"watch.execApproval.\\(message.approval.id)\""))
#expect(source.contains("let ownerlessApprovals = state.execApprovals.filter"))
#expect(source.contains("self.lastExecApprovalSnapshotID = nil"))
#expect(source.contains("\"watch.execApproval.\\(approvalID)\""))
}
@Test func `setup route probes yield to newer manual actions`() throws {
@@ -872,13 +1171,29 @@ struct RootTabsSourceGuardTests {
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
let onboardingScannerSheet = try Self.extract(
onboardingSource,
from: "isPresented: self.$showQRScanner,",
to: ".sheet(isPresented: self.$showGatewayProblemDetails)")
let onboardingOnDismiss = try #require(onboardingScannerSheet.range(of: "onDismiss: {"))
let onboardingProcessing = try #require(onboardingScannerSheet.range(of: "self.processQueuedScannerResult()"))
let onboardingContent = try #require(onboardingScannerSheet.range(of: "content: {"))
#expect(appSource.contains("deferDiscoveryUntilLocalNetworkRequest: true"))
#expect(controllerSource.contains("func requestLocalNetworkAccess(reason: String)"))
#expect(appSource.contains("func application(\n _ app: UIApplication,\n open url: URL,"))
#expect(appSource.contains("self.pendingOpenURLs.append(url)"))
#expect(appSource.contains("model.stageGatewaySetupLink(link)"))
#expect(appSource.contains(".onOpenURL"))
#expect(appSource.contains("self.appDelegate.handleOpenURL(url, model: self.appModel)"))
#expect(controllerSource.contains(
"func requestLocalNetworkAccess(reason: String, allowAutoReconnect: Bool = true)"))
#expect(controllerSource.contains("guard self.localNetworkAccessRequested else"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_manual\")"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\")"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_last_known\")"))
#expect(controllerSource.contains(
"self.requestLocalNetworkAccess(reason: \"connect_manual\", allowAutoReconnect: false)"))
#expect(controllerSource.contains(
"self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\", allowAutoReconnect: false)"))
#expect(controllerSource.contains(
"self.requestLocalNetworkAccess(reason: \"connect_last_known\", allowAutoReconnect: false)"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"root_appear\")"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"scene_active\")"))
@@ -889,6 +1204,12 @@ struct RootTabsSourceGuardTests {
#expect(onboardingSource.contains("self.requestLocalNetworkAccess(reason: \"onboarding_continue\")"))
#expect(onboardingSource.contains("self.requestLocalNetworkAccessIfPastIntro(reason: \"onboarding_appear\")"))
#expect(onboardingSource.contains(
"self.applyPendingGatewaySetupLinkIfNeeded()\n self.attemptAutomaticPairingResumeIfNeeded()"))
#expect(onboardingOnDismiss.lowerBound < onboardingProcessing.lowerBound)
#expect(onboardingProcessing.lowerBound < onboardingContent.lowerBound)
#expect(!onboardingSource.contains(".onChange(of: self.showQRScanner)"))
#expect(onboardingSource.matches(of: /self\.showQRScanner = true/).count == 1)
#expect(actionsSource
.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
}
@@ -913,14 +1234,49 @@ struct RootTabsSourceGuardTests {
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
let appModelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let transportSource = try String(contentsOf: Self.iOSGatewayChatTransportSourceURL(), encoding: .utf8)
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
#expect(appModelSource.contains("ifCurrentRoute: operatorRoute"))
#expect(transportSource.matches(of: /ifCurrentRoute: expectedRoute/).count == 3)
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
#expect(channelsSource.contains("label: \"ClickClack\""))
#expect(channelsSource.contains("Self-hosted chat bot routing."))
}
@Test func `deferred gateway mutations retain their source gateway`() throws {
let source = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
let pendingActions = try Self.extract(
source,
from: "private func resumePendingForegroundNodeActionsIfNeeded(",
to: "private func handleWatchQuickReply(")
let resolvedState = try Self.extract(
source,
from: "private func handleExecApprovalResolvedForCurrentGateway(",
to: "func handleExecApprovalResolvedRemotePush(")
let resolvedPushes = try Self.extract(
source,
from: "func handleExecApprovalResolvedRemotePush(",
to: "func handleSilentPushWake(")
#expect(pendingActions.contains("ifCurrentRoute: nodeRoute"))
#expect(pendingActions.contains("ifCurrentRoute: expectedRoute"))
#expect(pendingActions.contains("isCurrentGatewaySessionRoute"))
#expect(pendingActions.contains("pendingForegroundActionDrainRequested = true"))
#expect(pendingActions.contains("trigger: \"coalesced\""))
#expect(pendingActions.contains("pendingForegroundActionDrainInFlight = false"))
#expect(pendingActions.contains("completedPendingForegroundActionIDsByGateway"))
#expect(pendingActions.contains("presentIn: decoded.actions"))
#expect(pendingActions.contains("let currentRoute = await self.nodeGateway.currentRoute()"))
#expect(pendingActions.contains("ifCurrentRoute: expectedRoute"))
#expect(resolvedState.matches(of: /canApplyExecApprovalResolvedState/).count >= 4)
#expect(resolvedState.contains("routeContext: routeContext"))
#expect(resolvedPushes.contains("applyValidatedExecApprovalResolvedPush(push, context: context)"))
#expect(resolvedPushes.contains("session: self.operatorGateway"))
#expect(resolvedPushes.contains("generation: context.routeGeneration"))
}
private static func rootTabsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -935,6 +1291,13 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Model/NodeAppModel.swift")
}
private static func iOSGatewayChatTransportSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Chat/IOSGatewayChatTransport.swift")
}
private static func phoneHubSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -1099,6 +1462,13 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
}
private static func qrScannerSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Onboarding/QRScannerView.swift")
}
private static func openClawAppSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -1141,6 +1511,20 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift")
}
private static func watchConnectivityReceiverSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("WatchApp/Sources/WatchConnectivityReceiver.swift")
}
private static func watchInboxStoreSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("WatchApp/Sources/WatchInboxStore.swift")
}
private static func channelsSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
+55 -2
View File
@@ -7,7 +7,9 @@ struct TerminalHubScreenTests {
private static func makeConfig(
url: URL,
token: String? = nil,
password: String? = nil) -> GatewayConnectConfig
password: String? = nil,
allowStoredDeviceAuth: Bool = true,
deviceAuthGatewayID: String? = nil) -> GatewayConnectConfig
{
GatewayConnectConfig(
url: url,
@@ -24,7 +26,9 @@ struct TerminalHubScreenTests {
permissions: [:],
clientId: "ios",
clientMode: "node",
clientDisplayName: "Phone"))
clientDisplayName: "Phone",
allowStoredDeviceAuth: allowStoredDeviceAuth,
deviceAuthGatewayID: deviceAuthGatewayID))
}
@Test func `terminal URL flips scheme and carries only view parameter`() throws {
@@ -77,6 +81,55 @@ struct TerminalHubScreenTests {
#expect(script?.contains("\"token\":\"stored-token\"") == true)
}
@Test func `auth user script loads the active gateway scoped operator token`() throws {
let gatewayID = "manual|terminal-\(UUID().uuidString)|443"
let identity = DeviceIdentityStore.loadOrCreate()
defer {
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)
}
#expect(DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "scoped-terminal-token",
gatewayID: gatewayID).token == "scoped-terminal-token")
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443")),
deviceAuthGatewayID: gatewayID)
let script = TerminalHubScreen.terminalAuthUserScript(config: config)
#expect(script?.contains("\"token\":\"scoped-terminal-token\"") == true)
}
@Test func `auth user script honors stored device auth suppression`() throws {
let gatewayID = "manual|terminal-suppressed-\(UUID().uuidString)|443"
let identity = DeviceIdentityStore.loadOrCreate()
defer {
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: "operator",
gatewayID: gatewayID)
}
#expect(DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "stale-terminal-token",
gatewayID: gatewayID).token == "stale-terminal-token")
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443")),
password: "replacement-password",
allowStoredDeviceAuth: false,
deviceAuthGatewayID: gatewayID)
let script = TerminalHubScreen.terminalAuthUserScript(config: config)
#expect(script?.contains("stale-terminal-token") == false)
#expect(script?.contains("\"password\":\"replacement-password\"") == true)
}
@Test func `web content identity changes with stored operator token`() throws {
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")))
@@ -0,0 +1,29 @@
import Testing
struct WatchDeferredPayloadOrderingTests {
@Test func `expired payload is not replayable`() {
#expect(WatchDeferredPayloadOrdering.isExpired(expiresAtMs: 100, nowMs: 100))
#expect(!WatchDeferredPayloadOrdering.isExpired(expiresAtMs: 101, nowMs: 100))
#expect(!WatchDeferredPayloadOrdering.isExpired(expiresAtMs: nil, nowMs: 100))
}
@Test func `ownerless snapshot retains only payloads it cannot supersede`() {
#expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: 201, snapshotSentAtMs: 200))
#expect(!WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: 200, snapshotSentAtMs: 200))
#expect(WatchDeferredPayloadOrdering.isNewerThanSnapshot(payloadSentAtMs: nil, snapshotSentAtMs: 200))
}
@Test func `snapshot freshness treats an undated payload as preexisting`() {
#expect(WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: 100, snapshotSentAtMs: 100))
#expect(!WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: 101, snapshotSentAtMs: 100))
#expect(WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(payloadSentAtMs: nil, snapshotSentAtMs: 100))
}
@Test func `replays reversed deliveries in event order`() {
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [200, 100]) == [1, 0])
}
@Test func `replays missing timestamps first in receipt order`() {
#expect(WatchDeferredPayloadOrdering.indicesOldestFirst(for: [nil, 200, nil, 100]) == [0, 2, 3, 1])
}
}
@@ -27,12 +27,13 @@ struct OpenClawWatchApp: App {
self.inboxStore.markReplyResult(result, actionLabel: action.label)
}
},
onExecApprovalDecision: { approvalId, decision in
onExecApprovalDecision: { approvalId, gatewayStableID, decision in
guard let receiver = self.receiver else { return }
self.inboxStore.markExecApprovalSending(approvalId: approvalId, decision: decision)
Task { @MainActor in
let result = await receiver.sendExecApprovalResolve(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision)
self.inboxStore.markExecApprovalSendResult(
approvalId: approvalId,
@@ -144,10 +145,11 @@ struct OpenClawWatchApp: App {
extension WatchInboxStore {
fileprivate func configureScreenshotFixture() {
let sentAtMs = Int(Date().timeIntervalSince1970 * 1000)
self.greetingTextOverride = "Good morning"
greetingTextOverride = "Good morning"
self.consume(
execApprovalSnapshot: WatchExecApprovalSnapshotMessage(
approvals: [],
gatewayStableID: "watch-screenshot-gateway",
sentAtMs: sentAtMs,
snapshotId: nil),
transport: "screenshot")
@@ -130,6 +130,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
func sendExecApprovalResolve(
approvalId: String,
gatewayStableID: String?,
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
{
await self.ensureActivated()
@@ -144,6 +145,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let payload = Self.encodeExecApprovalResolvePayload(
WatchExecApprovalResolveMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID,
decision: decision,
replyId: UUID().uuidString,
sentAtMs: Self.nowMs()))
@@ -302,6 +304,8 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let risk = WatchRiskLevel(rawValue: riskRaw)
@@ -310,6 +314,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
return WatchExecApprovalItem(
id: id,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
commandText: commandText,
commandPreview: commandPreview,
host: host,
@@ -350,11 +355,14 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty else { return nil }
let decision = Self.parseExecApprovalDecision(payload["decision"])
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedAtMs = (payload["resolvedAtMs"] as? Int)
?? (payload["resolvedAtMs"] as? NSNumber)?.intValue
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalResolvedMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
decision: decision,
resolvedAtMs: resolvedAtMs,
source: source)
@@ -376,8 +384,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
return nil
}
let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalExpiredMessage(
approvalId: approvalId,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
reason: reason,
expiredAtMs: expiredAtMs)
}
@@ -393,10 +404,13 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
let approvals = (payload["approvals"] as? [Any] ?? []).compactMap { item in
Self.parseExecApprovalItem(item)
}
let gatewayStableID = (payload["gatewayStableID"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalSnapshotMessage(
approvals: approvals,
gatewayStableID: gatewayStableID?.isEmpty == false ? gatewayStableID : nil,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
}
@@ -557,6 +571,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
"decision": message.decision.rawValue,
"replyId": message.replyId,
]
if let gatewayStableID = message.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayStableID.isEmpty
{
payload["gatewayStableID"] = gatewayStableID
}
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
@@ -602,6 +621,26 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
}
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
let appSnapshot = (payload[WatchPayloadType.appSnapshot.rawValue] as? [String: Any])
.flatMap(Self.parseAppSnapshotPayload)
let execApprovalSnapshot =
(payload[WatchPayloadType.execApprovalSnapshot.rawValue] as? [String: Any])
.flatMap(Self.parseExecApprovalSnapshotPayload)
if appSnapshot != nil || execApprovalSnapshot != nil {
// Owner state must land first so approvals are filtered against this context's route.
Task { @MainActor in
if let appSnapshot {
self.store.consume(appSnapshot: appSnapshot)
}
if let execApprovalSnapshot {
self.store.consume(execApprovalSnapshot: execApprovalSnapshot, transport: transport)
}
if appSnapshot != nil {
self.store.replayDeferredGatewayPayloads()
}
}
return
}
if let incoming = Self.parseNotificationPayload(payload) {
Task { @MainActor in
self.store.consume(message: incoming, transport: transport)
@@ -635,6 +674,7 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
if let snapshot = Self.parseAppSnapshotPayload(payload) {
Task { @MainActor in
self.store.consume(appSnapshot: snapshot)
self.store.replayDeferredGatewayPayloads()
}
return
}
@@ -0,0 +1,26 @@
enum WatchDeferredPayloadOrdering {
static func isExpired(expiresAtMs: Int?, nowMs: Int) -> Bool {
expiresAtMs.map { $0 <= nowMs } == true
}
static func isNewerThanSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
guard let payloadSentAtMs, let snapshotSentAtMs else { return true }
return payloadSentAtMs > snapshotSentAtMs
}
static func isAtOrBeforeSnapshot(payloadSentAtMs: Int?, snapshotSentAtMs: Int?) -> Bool {
guard let snapshotSentAtMs else { return false }
return payloadSentAtMs.map { $0 <= snapshotSentAtMs } ?? true
}
static func indicesOldestFirst(for timestamps: [Int?]) -> [Int] {
timestamps.indices.sorted { lhs, rhs in
let lhsTimestamp = timestamps[lhs] ?? .min
let rhsTimestamp = timestamps[rhs] ?? .min
if lhsTimestamp != rhsTimestamp {
return lhsTimestamp < rhsTimestamp
}
return lhs < rhs
}
}
}
+441 -22
View File
@@ -39,6 +39,7 @@ enum WatchExecApprovalCloseReason: String, Codable, Equatable {
struct WatchExecApprovalItem: Codable, Equatable, Identifiable {
var id: String
var gatewayStableID: String?
var commandText: String
var commandPreview: String?
var host: String?
@@ -58,6 +59,7 @@ struct WatchExecApprovalPromptMessage: Codable, Equatable {
struct WatchExecApprovalResolvedMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var decision: WatchExecApprovalDecision?
var resolvedAtMs: Int?
var source: String?
@@ -65,12 +67,14 @@ struct WatchExecApprovalResolvedMessage: Codable, Equatable {
struct WatchExecApprovalExpiredMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var reason: WatchExecApprovalCloseReason
var expiredAtMs: Int?
}
struct WatchExecApprovalSnapshotMessage: Codable, Equatable {
var approvals: [WatchExecApprovalItem]
var gatewayStableID: String?
var sentAtMs: Int?
var snapshotId: String?
}
@@ -82,6 +86,7 @@ struct WatchExecApprovalSnapshotRequestMessage: Codable, Equatable {
struct WatchExecApprovalResolveMessage: Codable, Equatable {
var approvalId: String
var gatewayStableID: String?
var decision: WatchExecApprovalDecision
var replyId: String
var sentAtMs: Int?
@@ -147,7 +152,7 @@ struct WatchPromptAction: Codable, Equatable, Identifiable {
var style: String?
}
struct WatchNotifyMessage {
struct WatchNotifyMessage: Codable {
var id: String?
var title: String
var body: String
@@ -177,6 +182,73 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
@MainActor @Observable final class WatchInboxStore {
private enum DeferredGatewayPayload: Codable {
case notification(message: WatchNotifyMessage, transport: String)
case execApprovalPrompt(message: WatchExecApprovalPromptMessage, transport: String)
case execApprovalResolved(message: WatchExecApprovalResolvedMessage)
case execApprovalExpired(message: WatchExecApprovalExpiredMessage)
case execApprovalSnapshot(message: WatchExecApprovalSnapshotMessage, transport: String)
var gatewayStableID: String? {
switch self {
case let .notification(message, _):
message.gatewayStableID
case let .execApprovalPrompt(message, _):
message.approval.gatewayStableID
case let .execApprovalResolved(message):
message.gatewayStableID
case let .execApprovalExpired(message):
message.gatewayStableID
case let .execApprovalSnapshot(message, _):
if let gatewayStableID = WatchInboxStore.normalizedGatewayID(message.gatewayStableID) {
gatewayStableID
} else {
WatchInboxStore.onlyGatewayStableID(in: message.approvals)
}
}
}
var sentAtMs: Int? {
switch self {
case let .notification(message, _):
message.sentAtMs
case let .execApprovalPrompt(message, _):
message.sentAtMs
case let .execApprovalResolved(message):
message.resolvedAtMs
case let .execApprovalExpired(message):
message.expiredAtMs
case let .execApprovalSnapshot(message, _):
message.sentAtMs
}
}
var expiresAtMs: Int? {
switch self {
case let .notification(message, _):
message.expiresAtMs
case let .execApprovalPrompt(message, _):
message.approval.expiresAtMs
case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot:
nil
}
}
var approvalPrompt: WatchExecApprovalItem? {
guard case let .execApprovalPrompt(message, _) = self else { return nil }
return message.approval
}
var isFullyRepresentedByExecApprovalSnapshot: Bool {
switch self {
case .execApprovalResolved, .execApprovalExpired, .execApprovalSnapshot:
true
case .notification, .execApprovalPrompt:
false
}
}
}
private struct PersistedState: Codable {
var title: String
var body: String
@@ -196,15 +268,19 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var execApprovals: [WatchExecApprovalRecord]
var selectedExecApprovalID: String?
var lastExecApprovalSnapshotID: String?
var lastExecApprovalSnapshotGatewayStableID: String?
var lastExecApprovalSnapshotSentAtMs: Int?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
var appSnapshot: WatchAppSnapshotMessage?
var appSnapshotUpdatedAt: Date?
var appSnapshotStatusText: String?
var appCommandStatusText: String?
var deferredGatewayPayloads: [DeferredGatewayPayload]?
}
private static let persistedStateKey = "watch.inbox.state.v2"
private static let maxDeferredGatewayPayloads = 32
private static let defaultTitle = "OpenClaw"
private static let defaultBody = "Waiting for messages from your iPhone."
private let defaults: UserDefaults
@@ -238,8 +314,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
var execApprovalReviewStatusText: String?
var execApprovalReviewStatusAt: Date?
private var lastExecApprovalSnapshotID: String?
private var lastExecApprovalSnapshotGatewayStableID: String?
private var lastExecApprovalSnapshotSentAtMs: Int?
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
private var lastDeliveryKey: String?
/// WatchConnectivity does not order application-context updates against user-info
/// transfers. Persist a bounded handoff queue so a new route's alert is not lost
/// before its owner snapshot arrives.
private var deferredGatewayPayloads: [DeferredGatewayPayload] = []
init(
defaults: UserDefaults = .standard,
@@ -347,6 +429,7 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
func consume(message: WatchNotifyMessage, transport: String) {
guard self.routeGatewayPayload(.notification(message: message, transport: transport)) else { return }
let messageID = message.id?
.trimmingCharacters(in: .whitespacesAndNewlines)
let deliveryKey = self.deliveryKey(
@@ -381,7 +464,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
identifier: deliveryKey,
title: normalizedTitle,
body: message.body,
risk: message.risk)
risk: message.risk,
stillCurrent: { self.lastDeliveryKey == deliveryKey })
}
}
@@ -389,22 +473,33 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
execApprovalPrompt message: WatchExecApprovalPromptMessage,
transport: String)
{
guard self.routeGatewayPayload(.execApprovalPrompt(message: message, transport: transport)) else { return }
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
self.upsertExecApproval(
message.approval,
transport: transport,
keepSelectionIfPossible: true,
resetResolvingState: message.resetResolvingState == true)
let approvalID = message.approval.id
let approvalGatewayID = message.approval.gatewayStableID
guard let notificationIdentifier = Self.execApprovalNotificationIdentifier(for: message.approval) else {
return
}
self.markExecApprovalReviewLoaded()
self.lastExecApprovalOutcomeText = nil
self.lastExecApprovalOutcomeAt = nil
Task {
await self.postLocalNotification(
identifier: "watch.execApproval.\(message.approval.id)",
identifier: notificationIdentifier,
title: "Exec approval required",
body: message.approval.commandPreview ?? message.approval.commandText,
risk: message.approval.risk?.rawValue)
risk: message.approval.risk?.rawValue,
stillCurrent: {
self.execApprovals.contains { record in
record.id == approvalID && record.approval.gatewayStableID == approvalGatewayID
}
})
}
}
@@ -412,20 +507,55 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
execApprovalSnapshot message: WatchExecApprovalSnapshotMessage,
transport: String)
{
let deferredPayload = DeferredGatewayPayload.execApprovalSnapshot(
message: message,
transport: transport)
if deferredPayload.gatewayStableID != nil {
guard self.routeGatewayPayload(deferredPayload) else { return }
}
let snapshotGatewayID = Self.normalizedGatewayID(deferredPayload.gatewayStableID)
let previousSnapshotGatewayID = Self.normalizedGatewayID(
self.lastExecApprovalSnapshotGatewayStableID)
let hasSameSnapshotOwner = snapshotGatewayID == previousSnapshotGatewayID
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
if let snapshotID, !snapshotID.isEmpty, snapshotID == lastExecApprovalSnapshotID {
if hasSameSnapshotOwner,
let snapshotID,
!snapshotID.isEmpty,
snapshotID == lastExecApprovalSnapshotID
{
return
}
if hasSameSnapshotOwner,
let sentAtMs = message.sentAtMs,
let lastSentAtMs = lastExecApprovalSnapshotSentAtMs,
sentAtMs < lastSentAtMs
{
return
}
let existingRecords = self.execApprovals
let existingRecordsByID = Dictionary(
uniqueKeysWithValues: execApprovals.map { ($0.id, $0) })
self.execApprovals = message.approvals.map { approval in
uniqueKeysWithValues: existingRecords.map { ($0.id, $0) })
self.execApprovals = message.approvals.filter { approval in
self.acceptsGatewayOwner(approval.gatewayStableID)
}.map { approval in
self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
existingRecord: existingRecordsByID[approval.id])
}
self.lastExecApprovalSnapshotID = snapshotID
if hasSameSnapshotOwner {
if let snapshotID, !snapshotID.isEmpty {
self.lastExecApprovalSnapshotID = snapshotID
}
if let sentAtMs = message.sentAtMs {
self.lastExecApprovalSnapshotSentAtMs = sentAtMs
}
} else {
self.lastExecApprovalSnapshotID = snapshotID
self.lastExecApprovalSnapshotSentAtMs = message.sentAtMs
}
self.lastExecApprovalSnapshotGatewayStableID = snapshotGatewayID
self.hasCompletedExecApprovalSnapshotRefreshInSession = true
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
@@ -435,6 +565,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
let currentNotificationIdentifiers = Set(execApprovals.compactMap { record in
Self.execApprovalNotificationIdentifier(for: record.approval)
})
let removedApprovals = existingRecords.map(\.approval).filter { approval in
guard let identifier = Self.execApprovalNotificationIdentifier(for: approval) else { return false }
return !currentNotificationIdentifiers.contains(identifier)
}
self.removeExecApprovalNotifications(approvals: removedApprovals)
self.markExecApprovalReviewLoaded()
self.persistState()
}
@@ -444,16 +582,48 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
if let snapshotID, !snapshotID.isEmpty, snapshotID == appSnapshot?.snapshotId {
return
}
var merged = message
if merged.chatItems == nil {
merged.chatItems = self.appSnapshot?.chatItems
if let sentAtMs = message.sentAtMs,
let currentSentAtMs = appSnapshot?.sentAtMs,
sentAtMs < currentSentAtMs
{
return
}
if merged.chatStatusText == nil {
merged.chatStatusText = self.appSnapshot?.chatStatusText
let hasExistingAppSnapshot = self.appSnapshot != nil
let previousGatewayID = Self.normalizedGatewayID(self.appSnapshot?.gatewayStableID)
let nextGatewayID = Self.normalizedGatewayID(message.gatewayStableID)
var merged = message
if hasExistingAppSnapshot, previousGatewayID == nextGatewayID {
if merged.chatItems == nil {
merged.chatItems = self.appSnapshot?.chatItems
}
if merged.chatStatusText == nil {
merged.chatStatusText = self.appSnapshot?.chatStatusText
}
}
self.appSnapshot = merged
self.appSnapshotUpdatedAt = Date()
self.appSnapshotStatusText = nil
if !hasExistingAppSnapshot || previousGatewayID != nextGatewayID {
if Self.normalizedGatewayID(self.gatewayStableID) != nextGatewayID {
self.clearMessagePrompt()
}
let invalidatedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
guard let nextGatewayID else { return record.approval }
return Self.normalizedGatewayID(record.approval.gatewayStableID) == nextGatewayID
? nil
: record.approval
}
self.execApprovals.removeAll { record in
guard let nextGatewayID else { return true }
return Self.normalizedGatewayID(record.approval.gatewayStableID) != nextGatewayID
}
self.removeExecApprovalNotifications(approvals: invalidatedApprovals)
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
}
self.persistState()
}
@@ -520,7 +690,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
self.removeExecApproval(id: message.approvalId)
guard self.routeGatewayPayload(.execApprovalResolved(message: message)) else { return }
self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID)
let statusText = switch message.decision {
case .allowOnce:
"Allowed once"
@@ -535,7 +706,8 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
}
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
self.removeExecApproval(id: message.approvalId)
guard self.routeGatewayPayload(.execApprovalExpired(message: message)) else { return }
self.removeExecApproval(id: message.approvalId, gatewayStableID: message.gatewayStableID)
let statusText = switch message.reason {
case .expired:
"Approval expired"
@@ -643,21 +815,204 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
statusAt: statusAt)
}
private func removeExecApproval(id: String) {
private func removeExecApproval(id: String, gatewayStableID: String?) {
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
self.execApprovals.removeAll { $0.id == normalizedID }
let normalizedGatewayID = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines)
let removedApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
guard record.id == normalizedID else { return nil }
// Legacy ownerless lifecycle messages may only close legacy ownerless prompts.
return record.approval.gatewayStableID == normalizedGatewayID ? record.approval : nil
}
self.execApprovals.removeAll { record in
guard record.id == normalizedID else { return false }
// Legacy ownerless lifecycle messages may only close legacy ownerless prompts.
return record.approval.gatewayStableID == normalizedGatewayID
}
self.removeExecApprovalNotifications(approvals: removedApprovals)
if self.selectedExecApprovalID == normalizedID {
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.persistState()
}
private func routeGatewayPayload(_ payload: DeferredGatewayPayload) -> Bool {
guard let incomingGatewayID = Self.normalizedGatewayID(payload.gatewayStableID) else {
return false
}
guard let activeSnapshot = appSnapshot else { return true }
let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID)
guard incomingGatewayID != activeGatewayID else { return true }
if let payloadSentAtMs = payload.sentAtMs,
let snapshotSentAtMs = activeSnapshot.sentAtMs,
payloadSentAtMs <= snapshotSentAtMs
{
return false
}
if WatchDeferredPayloadOrdering.isExpired(
expiresAtMs: payload.expiresAtMs,
nowMs: Self.nowMs())
{
return false
}
self.deferredGatewayPayloads.append(payload)
if self.deferredGatewayPayloads.count > Self.maxDeferredGatewayPayloads {
self.deferredGatewayPayloads.removeFirst(
self.deferredGatewayPayloads.count - Self.maxDeferredGatewayPayloads)
}
self.persistState()
return false
}
private func acceptsGatewayOwner(_ gatewayStableID: String?) -> Bool {
guard let incomingGatewayID = Self.normalizedGatewayID(gatewayStableID) else { return false }
guard let activeSnapshot = appSnapshot else { return true }
guard let activeGatewayID = Self.normalizedGatewayID(activeSnapshot.gatewayStableID) else { return false }
return incomingGatewayID == activeGatewayID
}
func replayDeferredGatewayPayloads() {
guard let activeGatewayID = Self.normalizedGatewayID(appSnapshot?.gatewayStableID) else {
let snapshotSentAtMs = self.appSnapshot?.sentAtMs
let nowMs = Self.nowMs()
self.deferredGatewayPayloads.removeAll { payload in
WatchDeferredPayloadOrdering.isExpired(
expiresAtMs: payload.expiresAtMs,
nowMs: nowMs)
|| !WatchDeferredPayloadOrdering.isNewerThanSnapshot(
payloadSentAtMs: payload.sentAtMs,
snapshotSentAtMs: snapshotSentAtMs)
}
self.persistState()
return
}
let snapshotSentAtMs = self.appSnapshot?.sentAtMs
let approvalSnapshotGatewayID = Self.normalizedGatewayID(
self.lastExecApprovalSnapshotGatewayStableID)
let nowMs = Self.nowMs()
var ready: [DeferredGatewayPayload] = []
var future: [DeferredGatewayPayload] = []
for payload in self.deferredGatewayPayloads {
if WatchDeferredPayloadOrdering.isExpired(
expiresAtMs: payload.expiresAtMs,
nowMs: nowMs)
{
continue
}
if Self.normalizedGatewayID(payload.gatewayStableID) == activeGatewayID {
let isPreexistingApprovalPayload = approvalSnapshotGatewayID == activeGatewayID
&& WatchDeferredPayloadOrdering.isAtOrBeforeSnapshot(
payloadSentAtMs: payload.sentAtMs,
snapshotSentAtMs: self.lastExecApprovalSnapshotSentAtMs)
if isPreexistingApprovalPayload,
payload.isFullyRepresentedByExecApprovalSnapshot
{
continue
}
if isPreexistingApprovalPayload,
let approval = payload.approvalPrompt,
!self.execApprovals.contains(where: { record in
record.id == approval.id
&& Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID
})
{
continue
}
ready.append(payload)
} else if let payloadSentAtMs = payload.sentAtMs,
let snapshotSentAtMs,
payloadSentAtMs > snapshotSentAtMs
{
future.append(payload)
}
}
self.deferredGatewayPayloads = future
self.persistState()
let replayOrder = WatchDeferredPayloadOrdering.indicesOldestFirst(
for: ready.map(\.sentAtMs))
for index in replayOrder {
let payload = ready[index]
switch payload {
case let .notification(message, transport):
self.consume(message: message, transport: transport)
case let .execApprovalPrompt(message, transport):
self.consume(execApprovalPrompt: message, transport: transport)
case let .execApprovalResolved(message):
self.consume(execApprovalResolved: message)
case let .execApprovalExpired(message):
self.consume(execApprovalExpired: message)
case let .execApprovalSnapshot(message, transport):
self.consume(execApprovalSnapshot: message, transport: transport)
}
}
}
private func clearMessagePrompt() {
let notificationIdentifier = self.lastDeliveryKey
self.title = Self.defaultTitle
self.body = Self.defaultBody
self.transport = "none"
self.updatedAt = nil
self.lastDeliveryKey = nil
self.promptId = nil
self.sessionKey = nil
self.gatewayStableID = nil
self.kind = nil
self.details = nil
self.expiresAtMs = nil
self.risk = nil
self.actions = []
self.replyStatusText = nil
self.replyStatusAt = nil
self.isReplySending = false
guard let notificationIdentifier else { return }
self.removeLocalNotifications(identifiers: [notificationIdentifier])
}
private func removeExecApprovalNotifications(approvals: [WatchExecApprovalItem]) {
self.removeLocalNotifications(identifiers: approvals.compactMap { approval in
Self.execApprovalNotificationIdentifier(for: approval)
})
}
private func removeLocalNotifications(identifiers: [String]) {
guard !identifiers.isEmpty else { return }
let center = UNUserNotificationCenter.current()
center.removePendingNotificationRequests(withIdentifiers: identifiers)
center.removeDeliveredNotifications(withIdentifiers: identifiers)
}
private nonisolated static func normalizedGatewayID(_ gatewayStableID: String?) -> String? {
let normalized = gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return normalized.isEmpty ? nil : normalized
}
private nonisolated static func onlyGatewayStableID(in approvals: [WatchExecApprovalItem]) -> String? {
let gatewayIDs = Set(approvals.compactMap { self.normalizedGatewayID($0.gatewayStableID) })
return gatewayIDs.count == 1 ? gatewayIDs.first : nil
}
private static func execApprovalNotificationIdentifier(for approval: WatchExecApprovalItem) -> String? {
guard let gatewayStableID = normalizedGatewayID(approval.gatewayStableID) else { return nil }
let approvalID = approval.id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !approvalID.isEmpty else { return nil }
return "watch.execApproval.\(gatewayStableID.utf8.count):\(gatewayStableID)\(approvalID)"
}
private func pruneExpiredExecApprovals(nowMs: Int) {
let expiredApprovals = self.execApprovals.compactMap { record -> WatchExecApprovalItem? in
guard let expiresAtMs = record.approval.expiresAtMs, expiresAtMs <= nowMs else { return nil }
return record.approval
}
self.execApprovals.removeAll { record in
guard let expiresAtMs = record.approval.expiresAtMs else { return false }
return expiresAtMs <= nowMs
}
self.removeExecApprovalNotifications(approvals: expiredApprovals)
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
@@ -688,15 +1043,64 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
self.actions = state.actions ?? []
self.replyStatusText = state.replyStatusText
self.replyStatusAt = state.replyStatusAt
self.execApprovals = state.execApprovals
self.selectedExecApprovalID = state.selectedExecApprovalID
let ownerlessApprovals = state.execApprovals.filter { record in
Self.normalizedGatewayID(record.approval.gatewayStableID) == nil
}
let taggedApprovals = state.execApprovals.filter { record in
Self.normalizedGatewayID(record.approval.gatewayStableID) != nil
}
let activeGatewayID = state.appSnapshot.flatMap { snapshot in
Self.normalizedGatewayID(snapshot.gatewayStableID)
}
let invalidatedApprovals: [WatchExecApprovalRecord]
if state.appSnapshot != nil {
self.execApprovals = taggedApprovals.filter { record in
Self.normalizedGatewayID(record.approval.gatewayStableID) == activeGatewayID
}
invalidatedApprovals = taggedApprovals.filter { record in
Self.normalizedGatewayID(record.approval.gatewayStableID) != activeGatewayID
}
} else {
self.execApprovals = taggedApprovals
invalidatedApprovals = []
}
selectedExecApprovalID = state.selectedExecApprovalID
self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID
self.lastExecApprovalSnapshotGatewayStableID = state.lastExecApprovalSnapshotGatewayStableID
self.lastExecApprovalSnapshotSentAtMs = state.lastExecApprovalSnapshotSentAtMs
self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText
self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt
self.appSnapshot = state.appSnapshot
self.appSnapshotUpdatedAt = state.appSnapshotUpdatedAt
self.appSnapshotStatusText = state.appSnapshotStatusText
self.appCommandStatusText = state.appCommandStatusText
self.deferredGatewayPayloads = Array(
(state.deferredGatewayPayloads ?? []).suffix(Self.maxDeferredGatewayPayloads))
if state.appSnapshot != nil,
Self.normalizedGatewayID(self.lastExecApprovalSnapshotGatewayStableID) != activeGatewayID
{
self.lastExecApprovalSnapshotID = nil
self.lastExecApprovalSnapshotGatewayStableID = nil
self.lastExecApprovalSnapshotSentAtMs = nil
}
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.removeExecApprovalNotifications(approvals: invalidatedApprovals.map(\.approval))
guard !ownerlessApprovals.isEmpty else { return }
// Older Watch state has no gateway owner and cannot be resolved safely after
// gateway switches. Drop it, clear its old alert keys, and force a fresh snapshot.
self.lastExecApprovalSnapshotID = nil
self.lastExecApprovalSnapshotGatewayStableID = nil
self.lastExecApprovalSnapshotSentAtMs = nil
self.removeLocalNotifications(identifiers: ownerlessApprovals.compactMap { record in
let approvalID = record.id.trimmingCharacters(in: .whitespacesAndNewlines)
return approvalID.isEmpty ? nil : "watch.execApproval.\(approvalID)"
})
}
private func persistState() {
@@ -720,12 +1124,15 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
execApprovals: execApprovals,
selectedExecApprovalID: selectedExecApprovalID,
lastExecApprovalSnapshotID: lastExecApprovalSnapshotID,
lastExecApprovalSnapshotGatewayStableID: lastExecApprovalSnapshotGatewayStableID,
lastExecApprovalSnapshotSentAtMs: lastExecApprovalSnapshotSentAtMs,
lastExecApprovalOutcomeText: lastExecApprovalOutcomeText,
lastExecApprovalOutcomeAt: lastExecApprovalOutcomeAt,
appSnapshot: appSnapshot,
appSnapshotUpdatedAt: appSnapshotUpdatedAt,
appSnapshotStatusText: appSnapshotStatusText,
appCommandStatusText: appCommandStatusText)
appCommandStatusText: appCommandStatusText,
deferredGatewayPayloads: deferredGatewayPayloads)
guard let data = try? JSONEncoder().encode(state) else { return }
self.defaults.set(data, forKey: Self.persistedStateKey)
}
@@ -794,7 +1201,14 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
self.persistState()
}
private func postLocalNotification(identifier: String, title: String, body: String, risk: String?) async {
private func postLocalNotification(
identifier: String,
title: String,
body: String,
risk: String?,
stillCurrent: @MainActor @Sendable () -> Bool = { true }) async
{
guard stillCurrent() else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
@@ -806,7 +1220,12 @@ struct WatchExecApprovalRecord: Codable, Equatable, Identifiable {
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false))
_ = try? await UNUserNotificationCenter.current().add(request)
let center = UNUserNotificationCenter.current()
_ = try? await center.add(request)
guard stillCurrent() else {
self.removeLocalNotifications(identifiers: [identifier])
return
}
WKInterfaceDevice.current().play(self.mapHapticRisk(risk))
}
+41 -29
View File
@@ -4,7 +4,7 @@ import WatchKit
struct WatchInboxView: View {
var store: WatchInboxStore
var onAction: ((WatchPromptAction) -> Void)?
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
var onRefreshExecApprovalReview: (() -> Void)?
var onRefreshAppSnapshot: (() -> Void)?
var onAppCommand: ((WatchAppCommand) -> Void)?
@@ -28,7 +28,7 @@ struct WatchInboxView: View {
private struct WatchControlSurfaceView: View {
var store: WatchInboxStore
var onAction: ((WatchPromptAction) -> Void)?
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
var onExecApprovalDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
var onRefreshExecApprovalReview: (() -> Void)?
var onRefreshAppSnapshot: (() -> Void)?
var onAppCommand: ((WatchAppCommand) -> Void)?
@@ -183,7 +183,7 @@ private struct WatchControlSurfaceView: View {
subtitle: self.store.body,
accessory: self.updatedText)
if let details = self.promptDetails {
if let details = promptDetails {
WatchDetailText(text: details)
}
@@ -197,7 +197,7 @@ private struct WatchControlSurfaceView: View {
.disabled(self.store.isReplySending)
}
if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty {
if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty {
WatchTinyStatus(text: replyStatusText)
}
}
@@ -249,13 +249,19 @@ private struct WatchControlSurfaceView: View {
HStack(spacing: 8) {
if record.approval.allowedDecisions.contains(.allowOnce) {
WatchDecisionButton(title: "Approve", color: .green) {
self.onExecApprovalDecision?(record.id, .allowOnce)
self.onExecApprovalDecision?(
record.id,
record.approval.gatewayStableID,
.allowOnce)
}
}
if record.approval.allowedDecisions.contains(.deny) {
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
self.onExecApprovalDecision?(record.id, .deny)
self.onExecApprovalDecision?(
record.id,
record.approval.gatewayStableID,
.deny)
}
}
}
@@ -307,7 +313,7 @@ private struct WatchControlSurfaceView: View {
}
@ViewBuilder private var primaryDestination: some View {
if let record = self.store.activeExecApproval {
if let record = store.activeExecApproval {
WatchExecApprovalDetailView(
store: self.store,
record: record,
@@ -334,7 +340,7 @@ private struct WatchControlSurfaceView: View {
}
private var connectionLine: String {
if let snapshot = self.store.appSnapshot {
if let snapshot = store.appSnapshot {
return snapshot.gatewayConnected ? "AI agent online" : "Reconnect on iPhone"
}
return "Pair iPhone"
@@ -346,7 +352,7 @@ private struct WatchControlSurfaceView: View {
}
private var primaryTitle: String {
if let record = self.store.activeExecApproval {
if let record = store.activeExecApproval {
return record.approval.commandPreview ?? record.approval.commandText
}
if self.chatCount > 0 {
@@ -370,7 +376,7 @@ private struct WatchControlSurfaceView: View {
}
private var approvalSubtitle: String {
guard let record = self.store.activeExecApproval else { return "No approvals waiting" }
guard let record = store.activeExecApproval else { return "No approvals waiting" }
return record.approval.commandPreview ?? record.approval.commandText
}
@@ -380,7 +386,7 @@ private struct WatchControlSurfaceView: View {
private func approvalDecisionSubtitle(_ record: WatchExecApprovalRecord) -> String {
var parts: [String] = []
if let expiresText = self.expiryText(record.approval.expiresAtMs) {
if let expiresText = expiryText(record.approval.expiresAtMs) {
parts.append("Expires in \(expiresText)")
}
if let host = record.approval.host, !host.isEmpty {
@@ -396,7 +402,7 @@ private struct WatchControlSurfaceView: View {
if record.isResolving {
return "Sending"
}
if let risk = self.approvalRiskText(record.approval.risk) {
if let risk = approvalRiskText(record.approval.risk) {
return risk
}
return "Review"
@@ -416,7 +422,7 @@ private struct WatchControlSurfaceView: View {
}
private var chatPreviewTitle: String {
guard let item = self.chatItems.last else { return "No chat synced" }
guard let item = chatItems.last else { return "No chat synced" }
return self.roleTitle(item.role)
}
@@ -425,7 +431,7 @@ private struct WatchControlSurfaceView: View {
}
private var chatStatusText: String {
if let status = self.store.appSnapshot?.chatStatusText, !status.isEmpty {
if let status = store.appSnapshot?.chatStatusText, !status.isEmpty {
return status
}
if self.chatCount > 0 {
@@ -435,14 +441,14 @@ private struct WatchControlSurfaceView: View {
}
private var chatSendStatusText: String? {
guard let status = self.store.appCommandStatusText, status.hasPrefix("Chat:") else {
guard let status = store.appCommandStatusText, status.hasPrefix("Chat:") else {
return nil
}
return status
}
private var greetingText: String {
if let greetingTextOverride = self.store.greetingTextOverride {
if let greetingTextOverride = store.greetingTextOverride {
return greetingTextOverride
}
let hour = Calendar.current.component(.hour, from: Date())
@@ -452,20 +458,20 @@ private struct WatchControlSurfaceView: View {
}
private var statusLine: String {
if let status = self.store.appSnapshotStatusText, !status.isEmpty {
if let status = store.appSnapshotStatusText, !status.isEmpty {
return status
}
if let commandStatus = self.store.appCommandStatusText, !commandStatus.isEmpty {
if let commandStatus = store.appCommandStatusText, !commandStatus.isEmpty {
return commandStatus
}
if let replyStatus = self.store.replyStatusText, !replyStatus.isEmpty {
if let replyStatus = store.replyStatusText, !replyStatus.isEmpty {
return replyStatus
}
return self.store.hasAppSnapshot ? "Synced" : "Waiting for iPhone"
}
private var updatedText: String {
guard let updatedAt = self.store.updatedAt else { return "Just now" }
guard let updatedAt = store.updatedAt else { return "Just now" }
return updatedAt.formatted(date: .omitted, time: .shortened)
}
@@ -538,7 +544,7 @@ private enum WatchAvatarSource {
}
static func dataImage(from source: String?) -> UIImage? {
guard let source = self.normalized(source),
guard let source = normalized(source),
source.lowercased().hasPrefix("data:image/"),
let commaIndex = source.firstIndex(of: ",")
else {
@@ -552,7 +558,7 @@ private enum WatchAvatarSource {
}
static func remoteURL(from source: String?) -> URL? {
guard let source = self.normalized(source),
guard let source = normalized(source),
let url = URL(string: source),
let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http"
@@ -589,11 +595,11 @@ private struct WatchClawAvatar: View {
}
@ViewBuilder private var avatarContent: some View {
if let image = self.dataImage {
if let image = dataImage {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else if let url = WatchAvatarSource.remoteURL(from: self.imageSource) {
} else if let url = WatchAvatarSource.remoteURL(from: imageSource) {
AsyncImage(url: url) { phase in
switch phase {
case let .success(image):
@@ -610,7 +616,7 @@ private struct WatchClawAvatar: View {
}
@ViewBuilder private var fallbackContent: some View {
if let text = WatchAvatarSource.normalized(self.text) {
if let text = WatchAvatarSource.normalized(text) {
Text(String(text.prefix(3)))
.font(WatchClawType.avatar(size: self.size * 0.42))
.foregroundStyle(.white)
@@ -1289,7 +1295,7 @@ private enum WatchNativeTextInput {
private struct WatchExecApprovalListView: View {
var store: WatchInboxStore
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
var onDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
var body: some View {
WatchDetailScroll(title: "Approvals") {
@@ -1353,7 +1359,7 @@ private struct WatchExecApprovalListView: View {
private struct WatchExecApprovalDetailView: View {
var store: WatchInboxStore
let record: WatchExecApprovalRecord
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
var onDecision: ((String, String?, WatchExecApprovalDecision) -> Void)?
var body: some View {
WatchDetailScroll(title: "Approval") {
@@ -1375,13 +1381,19 @@ private struct WatchExecApprovalDetailView: View {
HStack(spacing: 8) {
if currentRecord.approval.allowedDecisions.contains(.allowOnce) {
WatchDecisionButton(title: "Approve", color: .green) {
self.onDecision?(currentRecord.id, .allowOnce)
self.onDecision?(
currentRecord.id,
currentRecord.approval.gatewayStableID,
.allowOnce)
}
}
if currentRecord.approval.allowedDecisions.contains(.deny) {
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
self.onDecision?(currentRecord.id, .deny)
self.onDecision?(
currentRecord.id,
currentRecord.approval.gatewayStableID,
.deny)
}
}
}
+2
View File
@@ -372,6 +372,8 @@ targets:
- path: Tests
excludes:
- Logic
- path: WatchApp/Sources/WatchDeferredPayloadOrdering.swift
group: WatchApp/Sources
dependencies:
- target: OpenClaw
- package: Swabble
@@ -237,7 +237,7 @@ struct GatewayChannelConnectTests {
@Test func `stored device token connect scopes reuse cached scopes`() async throws {
try await self.withTemporaryStateDir {
let identity = DeviceIdentityStore.loadOrCreate()
let storedEntry = DeviceAuthStore.storeToken(
let storedEntry: DeviceAuthEntry = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "operator",
token: "bootstrap-device-token",
@@ -74,8 +74,17 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
}
public var websocketURL: URL? {
let scheme = self.tls ? "wss" : "ws"
return URL(string: "\(scheme)://\(self.host):\(self.port)")
guard (1...65535).contains(self.port) else { return nil }
var components = URLComponents()
components.scheme = self.tls ? "wss" : "ws"
components.host = self.host
components.port = self.port
return components.url
}
public var isValidEndpoint: Bool {
guard (1...65535).contains(self.port), self.websocketURL?.host != nil else { return false }
return self.tls || LoopbackHost.isLocalNetworkHost(self.host)
}
public var connectionEndpoints: [GatewayConnectEndpoint] {
@@ -196,7 +205,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
if !tls, !LoopbackHost.isLocalNetworkHost(host) {
return nil
}
return GatewayConnectDeepLink(
return GatewayConnectDeepLink.validated(
host: host,
port: payload.port ?? defaultGatewayPort(tls: tls),
tls: tls,
@@ -223,7 +232,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
if !tls, !LoopbackHost.isLocalNetworkHost(hostname) {
return nil
}
return GatewayConnectDeepLink(
return GatewayConnectDeepLink.validated(
host: hostname,
port: parsed.port ?? defaultGatewayPort(tls: tls),
tls: tls,
@@ -232,6 +241,24 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
password: password)
}
fileprivate static func validated(
host: String,
port: Int,
tls: Bool,
bootstrapToken: String?,
token: String?,
password: String?) -> GatewayConnectDeepLink?
{
let link = GatewayConnectDeepLink(
host: host,
port: port,
tls: tls,
bootstrapToken: bootstrapToken,
token: token,
password: password)
return link.isValidEndpoint ? link : nil
}
private static func decodeBase64Url(_ input: String) -> Data? {
var base64 = input
.replacingOccurrences(of: "-", with: "+")
@@ -342,18 +369,27 @@ public enum DeepLinkParser {
return nil
}
let tls = (query["tls"] as NSString?)?.boolValue ?? false
let port = query["port"].flatMap { Int($0) } ?? defaultGatewayPort(tls: tls)
let port: Int
if let rawPort = query["port"] {
guard let parsedPort = Int(rawPort) else { return nil }
port = parsedPort
} else {
port = defaultGatewayPort(tls: tls)
}
if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) {
return nil
}
return .gateway(
.init(
host: hostParam,
port: port,
tls: tls,
bootstrapToken: nil,
token: query["token"],
password: query["password"]))
guard let link = GatewayConnectDeepLink.validated(
host: hostParam,
port: port,
tls: tls,
bootstrapToken: nil,
token: query["token"],
password: query["password"])
else {
return nil
}
return .gateway(link)
case "dashboard":
return .dashboard
@@ -5,12 +5,14 @@ public struct DeviceAuthEntry: Codable, Sendable {
public let role: String
public let scopes: [String]
public let updatedAtMs: Int
public let gatewayID: String?
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
public init(token: String, role: String, scopes: [String], updatedAtMs: Int, gatewayID: String? = nil) {
self.token = token
self.role = role
self.scopes = scopes
self.updatedAtMs = updatedAtMs
self.gatewayID = gatewayID
}
}
@@ -24,11 +26,11 @@ public enum DeviceAuthStore {
public static func loadToken(
deviceId: String,
role: String,
gatewayID: String? = nil,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
return store.tokens[self.tokenKey(role: role, gatewayID: gatewayID)]
}
public static func storeToken(
@@ -36,7 +38,25 @@ public enum DeviceAuthStore {
role: String,
token: String,
scopes: [String] = [],
gatewayID: String? = nil,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
{
self.storeTokenResult(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
gatewayID: gatewayID,
profile: profile).entry
}
static func storeTokenResult(
deviceId: String,
role: String,
token: String,
scopes: [String] = [],
gatewayID: String? = nil,
profile: GatewayDeviceIdentityProfile = .primary) -> (entry: DeviceAuthEntry, persisted: Bool)
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore(profile: profile)
@@ -47,26 +67,31 @@ public enum DeviceAuthStore {
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000),
gatewayID: self.normalizeGatewayID(gatewayID))
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store, profile: profile)
}
return entry
next?.tokens[self.tokenKey(role: normalizedRole, gatewayID: gatewayID)] = entry
let persisted = next.map { self.writeStore($0, profile: profile) } ?? false
return (entry, persisted)
}
public static func clearToken(
deviceId: String,
role: String,
gatewayID: String? = nil,
profile: GatewayDeviceIdentityProfile = .primary)
{
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
if gatewayID == nil {
store.tokens = store.tokens.filter { _, entry in
self.normalizeRole(entry.role) != normalizedRole
}
} else {
store.tokens.removeValue(forKey: self.tokenKey(role: normalizedRole, gatewayID: gatewayID))
}
self.writeStore(store, profile: profile)
}
@@ -74,10 +99,66 @@ public enum DeviceAuthStore {
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
}
/// Claims one legacy role token for a caller-proven gateway identity.
/// Roles can have different gateway owners, so bulk migration is never safe.
@discardableResult
public static func migrateUnscopedToken(
deviceId: String,
role: String,
toGatewayID gatewayID: String,
profile: GatewayDeviceIdentityProfile = .primary) -> Bool
{
guard let gatewayID = self.normalizeGatewayID(gatewayID),
var store = self.readStore(profile: profile),
store.deviceId == deviceId
else { return false }
let normalizedRole = self.normalizeRole(role)
let legacyKey = self.tokenKey(role: normalizedRole, gatewayID: nil)
guard let entry = store.tokens[legacyKey], entry.gatewayID == nil else { return false }
let scopedKey = self.tokenKey(role: normalizedRole, gatewayID: gatewayID)
if store.tokens[scopedKey] == nil {
store.tokens[scopedKey] = DeviceAuthEntry(
token: entry.token,
role: normalizedRole,
scopes: entry.scopes,
updatedAtMs: entry.updatedAtMs,
gatewayID: gatewayID)
}
store.tokens.removeValue(forKey: legacyKey)
return self.writeStore(store, profile: profile)
}
/// Removes legacy tokens when the app cannot prove which gateway issued them.
@discardableResult
public static func discardUnscopedTokens(
deviceId: String,
profile: GatewayDeviceIdentityProfile = .primary) -> Int
{
guard var store = self.readStore(profile: profile), store.deviceId == deviceId else { return 0 }
let legacyKeys = store.tokens.compactMap { key, entry in entry.gatewayID == nil ? key : nil }
guard !legacyKeys.isEmpty else { return 0 }
for key in legacyKeys {
store.tokens.removeValue(forKey: key)
}
return self.writeStore(store, profile: profile) ? legacyKeys.count : 0
}
private static func normalizeRole(_ role: String) -> String {
role.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func normalizeGatewayID(_ gatewayID: String?) -> String? {
let trimmed = gatewayID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func tokenKey(role: String, gatewayID: String?) -> String {
let normalizedRole = self.normalizeRole(role)
guard let gatewayID = self.normalizeGatewayID(gatewayID) else { return normalizedRole }
return "\(gatewayID)\u{1F}\(normalizedRole)"
}
private static func normalizeScopes(_ scopes: [String]) -> [String] {
let trimmed = scopes
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
@@ -101,7 +182,11 @@ public enum DeviceAuthStore {
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
@discardableResult
private static func writeStore(
_ store: DeviceAuthStoreFile,
profile: GatewayDeviceIdentityProfile) -> Bool
{
let url = self.fileURL(profile: profile)
do {
try FileManager.default.createDirectory(
@@ -110,8 +195,9 @@ public enum DeviceAuthStore {
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
return true
} catch {
// best-effort only
return false
}
}
}
@@ -112,6 +112,12 @@ public struct GatewayConnectOptions: Sendable {
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
public var includeDeviceIdentity: Bool
/// Set false for an endpoint handoff whose explicit credentials (including none) must be
/// tried without reusing a device token issued by a different gateway.
public var allowStoredDeviceAuth: Bool
/// Stable gateway owner for device tokens. Nil preserves legacy unscoped storage for clients
/// that have not adopted endpoint ownership yet.
public var deviceAuthGatewayID: String?
public init(
role: String,
@@ -124,7 +130,9 @@ public struct GatewayConnectOptions: Sendable {
clientMode: String,
clientDisplayName: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
includeDeviceIdentity: Bool = true)
includeDeviceIdentity: Bool = true,
allowStoredDeviceAuth: Bool = true,
deviceAuthGatewayID: String? = nil)
{
self.role = role
self.scopes = scopes
@@ -137,6 +145,8 @@ public struct GatewayConnectOptions: Sendable {
self.clientDisplayName = clientDisplayName
self.deviceIdentityProfile = deviceIdentityProfile
self.includeDeviceIdentity = includeDeviceIdentity
self.allowStoredDeviceAuth = allowStoredDeviceAuth
self.deviceAuthGatewayID = deviceAuthGatewayID
}
}
@@ -276,10 +286,11 @@ public actor GatewayChannelActor {
private var keepaliveTask: Task<Void, Never>?
private var pendingDeviceTokenRetry = false
private var deviceTokenRetryBudgetUsed = false
private var issuedDeviceAuthRoles = Set<String>()
private var reconnectPausedForAuthFailure = false
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
private var connectOptions: GatewayConnectOptions?
private let disconnectHandler: (@Sendable (String) async -> Void)?
public init(
@@ -470,10 +481,14 @@ public actor GatewayChannelActor {
let requestedScopes = options.scopes
let scopesAreExplicit = options.scopesAreExplicit
let includeDeviceIdentity = options.includeDeviceIdentity
let allowStoredDeviceAuth = options.allowStoredDeviceAuth
let deviceAuthGatewayID = options.deviceAuthGatewayID
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
allowStoredDeviceAuth: allowStoredDeviceAuth,
deviceAuthGatewayID: deviceAuthGatewayID,
deviceIdentityProfile: deviceIdentityProfile,
deviceId: identity?.deviceId,
requestedScopes: requestedScopes)
@@ -564,11 +579,17 @@ public actor GatewayChannelActor {
try await self.task?.send(.data(data))
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(
let issuedRoles = try await self.handleConnectResponse(
response,
identity: identity,
role: role,
deviceAuthGatewayID: deviceAuthGatewayID,
deviceIdentityProfile: deviceIdentityProfile)
self.issuedDeviceAuthRoles.formUnion(issuedRoles)
if issuedRoles.contains(role) {
// Only a token persisted from this endpoint may unlock stored auth for its role.
self.connectOptions?.allowStoredDeviceAuth = true
}
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
@@ -589,6 +610,7 @@ public actor GatewayChannelActor {
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: role,
gatewayID: deviceAuthGatewayID,
profile: deviceIdentityProfile)
}
throw error
@@ -598,6 +620,8 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
allowStoredDeviceAuth: Bool,
deviceAuthGatewayID: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile,
deviceId: String?,
requestedScopes: [String]) -> SelectedConnectAuth
@@ -607,8 +631,12 @@ public actor GatewayChannelActor {
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedEntry =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
(includeDeviceIdentity && allowStoredDeviceAuth && deviceId != nil)
? DeviceAuthStore.loadToken(
deviceId: deviceId!,
role: role,
gatewayID: deviceAuthGatewayID,
profile: deviceIdentityProfile)
: nil
let storedToken = storedEntry?.token
let storedScopes = storedEntry?.scopes ?? []
@@ -793,22 +821,25 @@ public actor GatewayChannelActor {
return requestedScopes
}
@discardableResult
private func persistBootstrapHandoffToken(
deviceId: String,
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
deviceAuthGatewayID: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile) -> Bool
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
return false
}
_ = DeviceAuthStore.storeToken(
return DeviceAuthStore.storeTokenResult(
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes,
profile: deviceIdentityProfile)
gatewayID: deviceAuthGatewayID,
profile: deviceIdentityProfile).persisted
}
private func persistIssuedDeviceToken(
@@ -817,33 +848,36 @@ public actor GatewayChannelActor {
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
deviceAuthGatewayID: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile) -> Bool
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
return
return false
}
self.persistBootstrapHandoffToken(
return self.persistBootstrapHandoffToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
deviceAuthGatewayID: deviceAuthGatewayID,
deviceIdentityProfile: deviceIdentityProfile)
return
}
_ = DeviceAuthStore.storeToken(
return DeviceAuthStore.storeTokenResult(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
profile: deviceIdentityProfile)
gatewayID: deviceAuthGatewayID,
profile: deviceIdentityProfile).persisted
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
deviceAuthGatewayID: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws -> Set<String>
{
if res.ok == false {
let error = res.error
@@ -900,18 +934,23 @@ public actor GatewayChannelActor {
self.tickIntervalMs = Double(tick)
}
let auth = ok.auth
var issuedRoles = Set<String>()
if let identity {
if let deviceToken = auth["deviceToken"]?.value as? String {
let authRole = auth["role"]?.value as? String ?? role
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
self.persistIssuedDeviceToken(
if self.persistIssuedDeviceToken(
authSource: self.lastAuthSource,
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceAuthGatewayID: deviceAuthGatewayID,
deviceIdentityProfile: deviceIdentityProfile)
{
issuedRoles.insert(authRole)
}
}
if self.shouldPersistBootstrapHandoffTokens(),
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
@@ -925,12 +964,16 @@ public actor GatewayChannelActor {
}
let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])?
.compactMap { $0.value as? String } ?? []
self.persistBootstrapHandoffToken(
if self.persistBootstrapHandoffToken(
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceAuthGatewayID: deviceAuthGatewayID,
deviceIdentityProfile: deviceIdentityProfile)
{
issuedRoles.insert(authRole)
}
}
}
}
@@ -943,6 +986,11 @@ public actor GatewayChannelActor {
if let pushHandler = self.pushHandler {
Task { await pushHandler(.snapshot(ok)) }
}
return issuedRoles
}
public func currentIssuedDeviceAuthRoles() -> Set<String> {
self.issuedDeviceAuthRoles
}
private func listen() {
@@ -42,6 +42,12 @@ func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
return parsed.string ?? trimmed
}
/// Binds suspended work to one installed gateway channel generation.
/// Callers use this lease so an actor hop cannot retarget a payload to a replacement gateway.
public struct GatewayNodeSessionRoute: Sendable, Equatable {
fileprivate let channelGeneration: UInt64
}
public actor GatewayNodeSession {
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
private let decoder = JSONDecoder()
@@ -54,6 +60,7 @@ public actor GatewayNodeSession {
private var activePassword: String?
private var activeConnectOptionsKey: String?
private var activeSessionIdentity: ObjectIdentifier?
private var channelGeneration: UInt64 = 0
private var connectOptions: GatewayConnectOptions?
private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)?
@@ -164,6 +171,9 @@ public actor GatewayNodeSession {
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let allowStoredDeviceAuth = options.allowStoredDeviceAuth ? "1" : "0"
let deviceAuthGatewayID = options.deviceAuthGatewayID?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let permissions = options.permissions
.map { key, value in
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -182,6 +192,8 @@ public actor GatewayNodeSession {
clientDisplayName,
deviceIdentityProfile,
includeDeviceIdentity,
allowStoredDeviceAuth,
deviceAuthGatewayID,
permissions,
].joined(separator: "|")
}
@@ -212,11 +224,20 @@ public actor GatewayNodeSession {
self.onDisconnected = onDisconnected
self.onInvoke = onInvoke
let channelGeneration: UInt64
if shouldReconnect {
self.channelGeneration &+= 1
channelGeneration = self.channelGeneration
self.resetConnectionState()
if let existing = self.channel {
// Detach before suspension so callers cannot lease the old channel with
// the replacement generation while shutdown is in flight.
self.channel = nil
await existing.shutdown()
}
// A newer connect or disconnect can run while shutdown suspends. Never let the
// superseded call install its endpoint or credentials afterward.
guard self.channelGeneration == channelGeneration else { throw CancellationError() }
let channel = GatewayChannelActor(
url: url,
token: token,
@@ -224,11 +245,11 @@ public actor GatewayNodeSession {
password: password,
session: sessionBox,
pushHandler: { [weak self] push in
await self?.handlePush(push)
await self?.handlePush(push, channelGeneration: channelGeneration)
},
connectOptions: connectOptions,
disconnectHandler: { [weak self] reason in
await self?.handleChannelDisconnected(reason)
await self?.handleChannelDisconnected(reason, channelGeneration: channelGeneration)
})
self.channel = channel
self.activeURL = url
@@ -237,6 +258,8 @@ public actor GatewayNodeSession {
self.activePassword = password
self.activeConnectOptionsKey = nextOptionsKey
self.activeSessionIdentity = nextSessionIdentity
} else {
channelGeneration = self.channelGeneration
}
guard let channel = self.channel else {
@@ -247,7 +270,13 @@ public actor GatewayNodeSession {
do {
try await channel.connect()
guard self.channelGeneration == channelGeneration,
self.channel === channel
else { throw CancellationError() }
_ = await self.waitForSnapshot(timeoutMs: 500)
guard self.channelGeneration == channelGeneration,
self.channel === channel
else { throw CancellationError() }
await self.notifyConnectedIfNeeded()
} catch {
throw error
@@ -255,7 +284,8 @@ public actor GatewayNodeSession {
}
public func disconnect() async {
await self.channel?.shutdown()
self.channelGeneration &+= 1
let channel = self.channel
self.channel = nil
self.activeURL = nil
self.activeToken = nil
@@ -265,6 +295,12 @@ public actor GatewayNodeSession {
self.activeSessionIdentity = nil
self.hasEverConnected = false
self.resetConnectionState()
await channel?.shutdown()
}
public func currentIssuedDeviceAuthRoles() async -> Set<String> {
guard let channel else { return [] }
return await channel.currentIssuedDeviceAuthRoles()
}
public func currentCanvasHostUrl() -> String? {
@@ -300,16 +336,31 @@ public actor GatewayNodeSession {
return "\(host):\(port)"
}
public func sendEvent(event: String, payloadJSON: String?) async {
guard let channel = self.channel else { return }
public func currentRoute() -> GatewayNodeSessionRoute? {
guard self.channel != nil else { return nil }
return GatewayNodeSessionRoute(channelGeneration: self.channelGeneration)
}
@discardableResult
public func sendEvent(
event: String,
payloadJSON: String?,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async -> Bool
{
if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration {
return false
}
guard let channel = self.channel else { return false }
let params: [String: AnyCodable] = [
"event": AnyCodable(event),
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
]
do {
try await channel.send(method: "node.event", params: params)
return true
} catch {
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
@@ -324,7 +375,15 @@ public actor GatewayNodeSession {
try await channel.send(method: method, params: params)
}
public func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
public func request(
method: String,
paramsJSON: String?,
timeoutSeconds: Int = 15,
ifCurrentRoute expectedRoute: GatewayNodeSessionRoute? = nil) async throws -> Data
{
if let expectedRoute, expectedRoute.channelGeneration != self.channelGeneration {
throw CancellationError()
}
guard let channel = self.channel else {
throw NSError(domain: "Gateway", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
@@ -349,7 +408,8 @@ public actor GatewayNodeSession {
}
}
private func handlePush(_ push: GatewayPush) async {
private func handlePush(_ push: GatewayPush, channelGeneration: UInt64) async {
guard self.channelGeneration == channelGeneration else { return }
switch push {
case let .snapshot(ok):
self.pluginSurfaceUrls = self.normalizePluginSurfaceUrls(ok.pluginsurfaceurls)
@@ -361,7 +421,11 @@ public actor GatewayNodeSession {
self.markSnapshotReceived()
await self.notifyConnectedIfNeeded()
case let .event(evt):
await self.handleEvent(evt)
guard let channel = self.channel else { return }
await self.handleEvent(
evt,
channel: channel,
channelGeneration: channelGeneration)
default:
break
}
@@ -373,7 +437,8 @@ public actor GatewayNodeSession {
self.drainSnapshotWaiters(returning: false)
}
private func handleChannelDisconnected(_ reason: String) async {
private func handleChannelDisconnected(_ reason: String, channelGeneration: UInt64) async {
guard self.channelGeneration == channelGeneration else { return }
// The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh
// onConnected callback once a new snapshot arrives after reconnect.
self.resetConnectionState()
@@ -456,7 +521,11 @@ public actor GatewayNodeSession {
}
}
private func handleEvent(_ evt: EventFrame) async {
private func handleEvent(
_ evt: EventFrame,
channel: GatewayChannelActor,
channelGeneration: UInt64) async
{
self.broadcastServerEvent(evt)
guard evt.event == "node.invoke.request" else { return }
self.logger.info("node invoke request received")
@@ -477,9 +546,14 @@ public actor GatewayNodeSession {
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke)
// Invoke output belongs to the requesting channel. A target switch while the device
// command is running must discard it instead of disclosing it to the replacement.
guard self.channelGeneration == channelGeneration,
self.channel === channel
else { return }
self.logger.info(
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
await self.sendInvokeResult(request: request, response: response)
await self.sendInvokeResult(request: request, response: response, channel: channel)
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
}
@@ -497,8 +571,11 @@ public actor GatewayNodeSession {
}
}
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
guard let channel = self.channel else { return }
private func sendInvokeResult(
request: NodeInvokeRequestPayload,
response: BridgeInvokeResponse,
channel: GatewayChannelActor) async
{
self.logger.info(
"node invoke result sending id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
var params: [String: AnyCodable] = [
@@ -2,6 +2,7 @@ import Foundation
public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
public let gatewayURLString: String
public let gatewayStableID: String?
public let token: String?
public let password: String?
public let sessionKey: String
@@ -10,6 +11,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
public init(
gatewayURLString: String,
gatewayStableID: String? = nil,
token: String?,
password: String?,
sessionKey: String,
@@ -17,6 +19,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
deliveryTo: String? = nil)
{
self.gatewayURLString = gatewayURLString
self.gatewayStableID = gatewayStableID
self.token = token
self.password = password
self.sessionKey = sessionKey
@@ -26,7 +29,10 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
}
public enum ShareGatewayRelaySettings {
private static var suiteName: String { OpenClawAppGroup.identifier }
private static var suiteName: String {
OpenClawAppGroup.identifier
}
private static let relayConfigKey = "share.gatewayRelay.config.v1"
private static let lastEventKey = "share.gatewayRelay.event.v1"
@@ -39,6 +45,22 @@ public enum ShareGatewayRelaySettings {
return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data)
}
/// An endpoint is not a gateway identity. If the extension launches before the
/// host can prove a stable ID, discard unscoped device auth and use explicit auth only.
public static func loadConfigDiscardingUnscopedDeviceAuth() -> ShareGatewayRelayConfig? {
guard let config = self.loadConfig() else { return nil }
if let gatewayID = config.gatewayStableID?.trimmingCharacters(in: .whitespacesAndNewlines),
!gatewayID.isEmpty
{
return config
}
let identity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
DeviceAuthStore.discardUnscopedTokens(
deviceId: identity.deviceId,
profile: .shareExtension)
return config
}
public static func saveConfig(_ config: ShareGatewayRelayConfig) {
guard let data = try? JSONEncoder().encode(config) else { return }
self.defaults.set(data, forKey: self.relayConfigKey)
@@ -53,6 +53,7 @@ public struct OpenClawWatchAction: Codable, Sendable, Equatable {
public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
public var id: String
public var gatewayStableID: String?
public var commandText: String
public var commandPreview: String?
public var host: String?
@@ -64,6 +65,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
public init(
id: String,
gatewayStableID: String? = nil,
commandText: String,
commandPreview: String? = nil,
host: String? = nil,
@@ -74,6 +76,7 @@ public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Ident
risk: OpenClawWatchRisk? = nil)
{
self.id = id
self.gatewayStableID = gatewayStableID
self.commandText = commandText
self.commandPreview = commandPreview
self.host = host
@@ -109,18 +112,21 @@ public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatab
public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var gatewayStableID: String?
public var decision: OpenClawWatchExecApprovalDecision
public var replyId: String
public var sentAtMs: Int?
public init(
approvalId: String,
gatewayStableID: String? = nil,
decision: OpenClawWatchExecApprovalDecision,
replyId: String,
sentAtMs: Int? = nil)
{
self.type = .execApprovalResolve
self.approvalId = approvalId
self.gatewayStableID = gatewayStableID
self.decision = decision
self.replyId = replyId
self.sentAtMs = sentAtMs
@@ -130,18 +136,21 @@ public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equata
public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var gatewayStableID: String?
public var decision: OpenClawWatchExecApprovalDecision?
public var resolvedAtMs: Int?
public var source: String?
public init(
approvalId: String,
gatewayStableID: String? = nil,
decision: OpenClawWatchExecApprovalDecision? = nil,
resolvedAtMs: Int? = nil,
source: String? = nil)
{
self.type = .execApprovalResolved
self.approvalId = approvalId
self.gatewayStableID = gatewayStableID
self.decision = decision
self.resolvedAtMs = resolvedAtMs
self.source = source
@@ -151,16 +160,19 @@ public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equat
public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var gatewayStableID: String?
public var reason: OpenClawWatchExecApprovalCloseReason
public var expiredAtMs: Int?
public init(
approvalId: String,
gatewayStableID: String? = nil,
reason: OpenClawWatchExecApprovalCloseReason,
expiredAtMs: Int? = nil)
{
self.type = .execApprovalExpired
self.approvalId = approvalId
self.gatewayStableID = gatewayStableID
self.reason = reason
self.expiredAtMs = expiredAtMs
}
@@ -169,16 +181,19 @@ public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equata
public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvals: [OpenClawWatchExecApprovalItem]
public var gatewayStableID: String?
public var sentAtMs: Int?
public var snapshotId: String?
public init(
approvals: [OpenClawWatchExecApprovalItem],
gatewayStableID: String? = nil,
sentAtMs: Int? = nil,
snapshotId: String? = nil)
{
self.type = .execApprovalSnapshot
self.approvals = approvals
self.gatewayStableID = gatewayStableID
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
}
@@ -361,6 +376,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
public var priority: OpenClawNotificationPriority?
public var promptId: String?
public var sessionKey: String?
public var gatewayStableID: String?
public var kind: String?
public var details: String?
public var expiresAtMs: Int?
@@ -373,6 +389,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
priority: OpenClawNotificationPriority? = nil,
promptId: String? = nil,
sessionKey: String? = nil,
gatewayStableID: String? = nil,
kind: String? = nil,
details: String? = nil,
expiresAtMs: Int? = nil,
@@ -384,6 +401,7 @@ public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable {
self.priority = priority
self.promptId = promptId
self.sessionKey = sessionKey
self.gatewayStableID = gatewayStableID
self.kind = kind
self.details = details
self.expiresAtMs = expiresAtMs
@@ -2471,7 +2471,7 @@ public struct SessionsPatchParams: Codable, Sendable {
key: String,
agentid: String? = nil,
label: AnyCodable?,
category: AnyCodable?,
category: AnyCodable? = nil,
archived: Bool? = nil,
pinned: Bool? = nil,
thinkinglevel: AnyCodable?,
@@ -7199,7 +7199,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
alloweddecisions: [String]?,
agentid: String? = nil,
sessionkey: String?,
approvalreviewerdeviceids: [String]?,
approvalreviewerdeviceids: [String]? = nil,
turnsourcechannel: String?,
turnsourceto: String?,
turnsourceaccountid: String?,
@@ -5,6 +5,154 @@ import Testing
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test(.stateDirectoryIsolated)
func `device auth store reports failed durable writes`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
try Data().write(to: blocker)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", blocker.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let compatibleEntry: DeviceAuthEntry = DeviceAuthStore.storeToken(
deviceId: "unwritable-device",
role: "node",
token: "must-not-be-acknowledged")
let stored = DeviceAuthStore.storeTokenResult(
deviceId: "unwritable-device",
role: "node",
token: "must-not-be-acknowledged")
#expect(compatibleEntry.token == "must-not-be-acknowledged")
#expect(!stored.persisted)
#expect(DeviceAuthStore.loadToken(deviceId: "unwritable-device", role: "node") == nil)
}
@Test(.stateDirectoryIsolated)
func `device auth tokens are isolated by gateway owner`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let deviceID = "test-device"
_ = DeviceAuthStore.storeToken(deviceId: deviceID, role: "node", token: "legacy-token")
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") == nil)
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "gateway-a-token",
gatewayID: "gateway-a")
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "gateway-b-token",
gatewayID: "gateway-b")
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node")?.token == "legacy-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: "gateway-a")?.token == "gateway-a-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: "gateway-b")?.token == "gateway-b-token")
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: "gateway-a")?.token == "gateway-a-token")
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-b") == nil)
DeviceAuthStore.clearToken(deviceId: deviceID, role: "node")
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node", gatewayID: "gateway-a") == nil)
}
@Test(.stateDirectoryIsolated)
func `legacy device auth migration claims only the proven role`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let deviceID = "legacy-device"
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "legacy-node-token")
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "operator",
token: "legacy-operator-token")
#expect(DeviceAuthStore.migrateUnscopedToken(
deviceId: deviceID,
role: "node",
toGatewayID: "trusted-gateway"))
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: "trusted-gateway")?.token == "legacy-node-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "operator",
gatewayID: "trusted-gateway") == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "operator")?.token == "legacy-operator-token")
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: "other-gateway") == nil)
#expect(!DeviceAuthStore.migrateUnscopedToken(
deviceId: deviceID,
role: "node",
toGatewayID: "other-gateway"))
_ = DeviceAuthStore.storeToken(
deviceId: deviceID,
role: "node",
token: "ambiguous-legacy-token")
#expect(DeviceAuthStore.discardUnscopedTokens(deviceId: deviceID) == 2)
#expect(DeviceAuthStore.loadToken(deviceId: deviceID, role: "node") == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: deviceID,
role: "node",
gatewayID: "trusted-gateway")?.token == "legacy-node-token")
}
@Test
func `state directory override wins over shared app group storage`() {
let tempDir = FileManager.default.temporaryDirectory
@@ -48,7 +196,7 @@ struct DeviceIdentityStoreTests {
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
}
@Test
@Test(.stateDirectoryIsolated)
func `secondary profiles use separate identity and auth files`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -3,7 +3,28 @@ import Testing
struct GatewayModelsCompatibilityTests {
@Test
func messageActionParamsKeepsRequesterAccountAdditive() {
func `plugin approval request params keeps reviewer devices additive`() {
let params = PluginApprovalRequestParams(
pluginid: nil,
title: "Install plugin",
description: "Review requested",
severity: nil,
toolname: nil,
toolcallid: nil,
alloweddecisions: nil,
sessionkey: nil,
turnsourcechannel: nil,
turnsourceto: nil,
turnsourceaccountid: nil,
turnsourcethreadid: nil,
timeoutms: nil,
twophase: nil)
#expect(params.approvalreviewerdeviceids == nil)
}
@Test
func `message action params keeps requester account additive`() {
let params = MessageActionParams(
channel: "slack",
action: "member-info",
@@ -14,8 +35,7 @@ struct GatewayModelsCompatibilityTests {
sessionkey: nil,
sessionid: nil,
toolcontext: nil,
idempotencykey: "test"
)
idempotencykey: "test")
#expect(params.requesteraccountid == nil)
}
@@ -49,21 +49,63 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
}
}
private final class FirstCancelGate: @unchecked Sendable {
private let condition = NSCondition()
private var shouldBlock = true
private var started = false
private var released = false
func blockIfNeeded() {
self.condition.lock()
guard self.shouldBlock else {
self.condition.unlock()
return
}
self.shouldBlock = false
self.started = true
self.condition.broadcast()
while !self.released {
self.condition.wait()
}
self.condition.unlock()
}
func hasStarted() -> Bool {
self.condition.lock()
defer { self.condition.unlock() }
return self.started
}
func release() {
self.condition.lock()
self.released = true
self.condition.broadcast()
self.condition.unlock()
}
}
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private let connectError: [String: Any]?
private let cancelGate: FirstCancelGate?
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var connectDevice: [String: Any]?
private var sentRequestMethods: [String] = []
private var receivePhase = 0
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
init(helloAuth: [String: Any]? = nil, connectError: [String: Any]? = nil) {
init(
helloAuth: [String: Any]? = nil,
connectError: [String: Any]? = nil,
cancelGate: FirstCancelGate? = nil)
{
self.helloAuth = helloAuth
self.connectError = connectError
self.cancelGate = cancelGate
}
var state: URLSessionTask.State {
@@ -78,6 +120,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelGate?.blockIfNeeded()
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
@@ -97,9 +140,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
guard let data else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
obj["type"] as? String == "req",
obj["method"] as? String == "connect",
let id = obj["id"] as? String
let method = obj["method"] as? String
{
self.lock.withLock { self.sentRequestMethods.append(method) }
guard method == "connect", let id = obj["id"] as? String else { return }
let params = obj["params"] as? [String: Any]
let auth = (params?["auth"] as? [String: Any]) ?? [:]
let device = params?["device"] as? [String: Any]
@@ -119,6 +163,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.lock.withLock { self.connectDevice }
}
func sentRequestCount(method: String) -> Int {
self.lock.withLock { self.sentRequestMethods.count(where: { $0 == method }) }
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
@@ -166,6 +214,17 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.networkConnectionLost)))
}
func emitInvokeRequest(id: String, command: String) {
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
}
handler?(.success(.data(Self.invokeRequestData(id: id, command: command))))
}
private static func connectChallengeData(nonce: String) -> Data {
let frame: [String: Any] = [
"type": "event",
@@ -224,18 +283,38 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
]
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
private static func invokeRequestData(id: String, command: String) -> Data {
let frame: [String: Any] = [
"type": "event",
"event": "node.invoke.request",
"payload": [
"id": id,
"nodeId": "test-node",
"command": command,
"paramsJSON": "{}",
],
]
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
}
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let lock = NSLock()
private let helloAuth: [String: Any]?
private let connectError: [String: Any]?
private let cancelGate: FirstCancelGate?
private var tasks: [FakeGatewayWebSocketTask] = []
private var makeCount = 0
init(helloAuth: [String: Any]? = nil, connectError: [String: Any]? = nil) {
init(
helloAuth: [String: Any]? = nil,
connectError: [String: Any]? = nil,
cancelGate: FirstCancelGate? = nil)
{
self.helloAuth = helloAuth
self.connectError = connectError
self.cancelGate = cancelGate
}
func snapshotMakeCount() -> Int {
@@ -250,7 +329,10 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
_ = url
return self.lock.withLock {
self.makeCount += 1
let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth, connectError: self.connectError)
let task = FakeGatewayWebSocketTask(
helloAuth: self.helloAuth,
connectError: self.connectError,
cancelGate: self.cancelGate)
self.tasks.append(task)
return WebSocketTaskBox(task: task)
}
@@ -268,6 +350,18 @@ private actor SeqGapProbe {
}
}
private actor DisconnectProbe {
private var reasons: [String] = []
func record(_ reason: String) {
self.reasons.append(reason)
}
func values() -> [String] {
self.reasons
}
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
@@ -290,6 +384,243 @@ struct GatewayNodeSessionTests {
}
@Test
func `superseded channel callbacks do not reach replacement connection`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let disconnects = DisconnectProbe()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://first.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { reason in await disconnects.record("first:\(reason)") },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
try await gateway.connect(
url: #require(URL(string: "ws://second.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { reason in await disconnects.record("second:\(reason)") },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
for _ in 0..<20 {
await Task.yield()
}
let replacementDisconnects = await disconnects.values()
#expect(replacementDisconnects.isEmpty)
await gateway.disconnect()
for _ in 0..<20 {
await Task.yield()
}
let finalDisconnects = await disconnects.values()
#expect(finalDisconnects.isEmpty)
}
@Test
func `route bound operations never use a replacement channel`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://first.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
let firstRoute = try #require(await gateway.currentRoute())
try await gateway.connect(
url: #require(URL(string: "ws://second.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
let sent = await gateway.sendEvent(
event: "push.apns.register",
payloadJSON: "{}",
ifCurrentRoute: firstRoute)
#expect(!sent)
do {
_ = try await gateway.request(
method: "exec.approval.get",
paramsJSON: "{}",
ifCurrentRoute: firstRoute)
Issue.record("stale route request unexpectedly reached the replacement channel")
} catch is CancellationError {
// Expected: the route lease belongs to the first channel.
}
let replacementTask = try #require(session.latestTask())
#expect(replacementTask.sentRequestCount(method: "node.event") == 0)
#expect(replacementTask.sentRequestCount(method: "exec.approval.get") == 0)
}
@Test
func `disconnect during channel shutdown prevents stale channel install`() async throws {
let cancelGate = FirstCancelGate()
let session = FakeGatewayWebSocketSession(cancelGate: cancelGate)
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://first.example.invalid")),
token: "first-token",
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
let replacement = Task {
try await gateway.connect(
url: #require(URL(string: "ws://stale.example.invalid")),
token: "stale-token",
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
}
let deadline = ContinuousClock().now.advanced(by: .seconds(2))
while !cancelGate.hasStarted(), ContinuousClock().now < deadline {
await Task.yield()
}
#expect(cancelGate.hasStarted())
#expect(await gateway.currentRoute() == nil)
let release = Task.detached {
try? await Task.sleep(nanoseconds: 10_000_000)
cancelGate.release()
}
await gateway.disconnect()
await release.value
do {
try await replacement.value
Issue.record("superseded replacement unexpectedly connected")
} catch is CancellationError {
// Expected: disconnect advanced the generation while old-channel shutdown was suspended.
}
#expect(session.snapshotMakeCount() == 1)
}
@Test
func `invoke result is discarded after target switch`() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let invokeStarted = AsyncStream<Void>.makeStream()
let invokeRelease = AsyncStream<Void>.makeStream()
var startedIterator = invokeStarted.stream.makeAsyncIterator()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: ["camera.snap"],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://first.example.invalid")),
token: "first-token",
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { request in
invokeStarted.continuation.yield()
for await _ in invokeRelease.stream {
return BridgeInvokeResponse(
id: request.id,
ok: true,
payloadJSON: #"{"sensitive":"camera-result"}"#,
error: nil)
}
return BridgeInvokeResponse(id: request.id, ok: false, payloadJSON: nil, error: nil)
})
let firstTask = try #require(session.latestTask())
firstTask.emitInvokeRequest(id: "invoke-old", command: "camera.snap")
_ = await startedIterator.next()
try await gateway.connect(
url: #require(URL(string: "ws://replacement.example.invalid")),
token: "replacement-token",
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) })
let replacementTask = try #require(session.latestTask())
invokeRelease.continuation.yield()
invokeRelease.continuation.finish()
for _ in 0..<100 {
await Task.yield()
}
#expect(firstTask.sentRequestCount(method: "node.invoke.result") == 0)
#expect(replacementTask.sentRequestCount(method: "node.invoke.result") == 0)
await gateway.disconnect()
}
@Test(.stateDirectoryIsolated)
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -345,7 +676,129 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
@Test(.stateDirectoryIsolated)
func `credentialless setup handoff does not send a stored device token`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let identity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "node",
token: "previous-gateway-device-token")
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true,
allowStoredDeviceAuth: false)
try await gateway.connect(
url: #require(URL(string: "ws://new-gateway.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let task = try #require(session.latestTask())
let auth = try #require(task.latestConnectAuth())
#expect(auth["token"] == nil)
#expect(auth["bootstrapToken"] == nil)
#expect(auth["deviceToken"] == nil)
#expect(task.latestConnectDevice() != nil)
#expect(await gateway.currentIssuedDeviceAuthRoles() == [])
await gateway.disconnect()
}
@Test(.stateDirectoryIsolated)
func `stored device token cannot cross gateway owner`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let identity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: identity.deviceId,
role: "node",
token: "gateway-a-device-token",
gatewayID: "gateway-a")
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true,
allowStoredDeviceAuth: true,
deviceAuthGatewayID: "gateway-b")
try await gateway.connect(
url: #require(URL(string: "ws://gateway-b.example.invalid")),
token: nil,
bootstrapToken: nil,
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let auth = try #require(session.latestTask()?.latestConnectAuth())
#expect(auth["token"] == nil)
#expect(auth["deviceToken"] == nil)
#expect(DeviceAuthStore.loadToken(
deviceId: identity.deviceId,
role: "node",
gatewayID: "gateway-a")?.token == "gateway-a-device-token")
await gateway.disconnect()
}
@Test(.stateDirectoryIsolated)
func `share extension identity profile uses separate node identity and token store`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -556,7 +1009,7 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
@Test(.stateDirectoryIsolated)
func `bootstrap hello stores additional device tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -631,11 +1084,64 @@ struct GatewayNodeSessionTests {
"operator.talk.secrets",
"operator.write",
])
#expect(await gateway.currentIssuedDeviceAuthRoles() == ["node", "operator"])
await gateway.disconnect()
}
@Test
@Test(.stateDirectoryIsolated)
func `failed device token write is not reported as an issued role`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let blocker = tempDir.appendingPathComponent("not-a-directory", isDirectory: false)
try Data().write(to: blocker)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", blocker.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "node-device-token",
"role": "node",
"scopes": [],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "node",
clientDisplayName: "iOS Test",
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
#expect(await gateway.currentIssuedDeviceAuthRoles().isEmpty)
await gateway.disconnect()
}
@Test(.stateDirectoryIsolated)
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -697,7 +1203,7 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
@Test(.stateDirectoryIsolated)
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -760,7 +1266,7 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
@Test(.stateDirectoryIsolated)
func `private lan bootstrap persists handoff tokens for reconnect`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -0,0 +1,50 @@
import Testing
private actor StateDirectoryTestGate {
private var locked = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func acquire() async {
if !self.locked {
self.locked = true
return
}
await withCheckedContinuation { continuation in
self.waiters.append(continuation)
}
}
func release() {
guard !self.waiters.isEmpty else {
self.locked = false
return
}
self.waiters.removeFirst().resume()
}
}
/// Prevents process-wide state-directory overrides from crossing suite boundaries.
struct StateDirectoryIsolationTrait: TestTrait, TestScoping {
private static let gate = StateDirectoryTestGate()
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void) async throws
{
await Self.gate.acquire()
do {
try await function()
} catch {
await Self.gate.release()
throw error
}
await Self.gate.release()
}
}
extension Trait where Self == StateDirectoryIsolationTrait {
static var stateDirectoryIsolated: Self {
Self()
}
}
+2 -1
View File
@@ -54,7 +54,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
["SessionsMessagesUnsubscribeParams", ["agentId"]],
["SessionsAbortParams", ["agentId"]],
["SessionsListParams", ["archived"]],
["SessionsPatchParams", ["agentId", "archived", "pinned"]],
["SessionsPatchParams", ["agentId", "category", "archived", "pinned"]],
["SessionsResetParams", ["agentId"]],
[
"SessionsDeleteParams",
@@ -78,6 +78,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
["CronListParams", ["compact"]],
["CronRunLogEntry", ["errorReason", "failureNotificationDelivery"]],
["ExecApprovalRequestParams", ["requireDeliveryRoute", "suppressDelivery"]],
["PluginApprovalRequestParams", ["approvalReviewerDeviceIds"]],
["DevicePairSetupCodeResult", ["gatewayUrls"]],
["AgentSummary", ["thinkingLevels", "thinkingOptions", "thinkingDefault"]],
["ModelChoice", ["available"]],
+22 -3
View File
@@ -115,6 +115,10 @@ vi.mock("../infra/device-pairing.js", async () => {
};
});
vi.mock("../infra/device-identity.js", () => ({
loadOrCreateProcessDeviceIdentity: () => ({ deviceId: "gateway-device-1" }),
}));
vi.mock("../infra/push-apns.js", () => ({
loadApnsRegistration: loadApnsRegistrationMock,
loadApnsRegistrations: loadApnsRegistrationsMock,
@@ -168,7 +172,19 @@ describe("createExecApprovalIosPushDelivery", () => {
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
});
it("targets iOS devices when the active operator token includes operator.approvals", async () => {
it("does not target approval-only iOS devices that cannot validate gateway ownership", async () => {
mockPairedIosOperator(["operator.approvals"]);
const delivery = createExecApprovalIosPushDelivery({ log: {} });
const accepted = await delivery.handleRequested(approvalRequest("approval-no-read"));
expect(accepted).toBe(false);
expect(loadApnsRegistrationsMock).not.toHaveBeenCalled();
expect(sendApnsExecApprovalAlertMock).not.toHaveBeenCalled();
});
it("targets iOS devices when the active operator token can approve and validate ownership", async () => {
mockPairedIosOperator(["operator.approvals", "operator.read"]);
const delivery = createExecApprovalIosPushDelivery({ log: {} });
@@ -178,6 +194,9 @@ describe("createExecApprovalIosPushDelivery", () => {
expect(accepted).toBe(true);
expect(loadApnsRegistrationsMock).toHaveBeenCalledWith(["ios-device-1"]);
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledTimes(1);
expect(sendApnsExecApprovalAlertMock).toHaveBeenCalledWith(
expect.objectContaining({ gatewayDeviceId: "gateway-device-1" }),
);
});
it("loads APNs registrations in one bulk read for all visible iOS operators", async () => {
@@ -185,7 +204,7 @@ describe("createExecApprovalIosPushDelivery", () => {
pairedIosOperator({
deviceId: "ios-device-1",
publicKey: "pub-1",
scopes: ["operator.approvals"],
scopes: ["operator.approvals", "operator.read"],
token: "operator-token-1",
}),
pairedIosOperator({
@@ -193,7 +212,7 @@ describe("createExecApprovalIosPushDelivery", () => {
publicKey: "pub-2",
platform: "iPadOS 18",
approvedAtMs: 2,
scopes: ["operator.approvals"],
scopes: ["operator.approvals", "operator.write"],
token: "operator-token-2",
}),
);
+14 -5
View File
@@ -2,6 +2,7 @@
// Sends APNs request/resolution wakes to paired operator devices.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { getRuntimeConfig } from "../config/io.js";
import { loadOrCreateProcessDeviceIdentity } from "../infra/device-identity.js";
import {
hasEffectivePairedDeviceRole,
listDevicePairing,
@@ -25,9 +26,11 @@ import {
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
// iOS exec-approval push delivery targets paired operator devices with APNs
// registrations. Request pushes require approval scope; cleanup/resolved pushes
// reuse the original targets so badges can clear even after scope changes.
// registrations. Request pushes require approval scope plus identity-read access
// so the client can validate gateway ownership before presenting or resolving.
// Cleanup pushes reuse original targets so badges can clear after scope changes.
const APPROVALS_SCOPE = "operator.approvals";
const READ_SCOPE = "operator.read";
const OPERATOR_ROLE = "operator";
type GatewayLikeLogger = {
@@ -82,14 +85,14 @@ function resolveActiveOperatorToken(device: PairedDevice): DeviceAuthToken | nul
return operatorToken;
}
function canApproveExecRequests(device: PairedDevice): boolean {
function canReceiveExecApprovalRequests(device: PairedDevice): boolean {
const operatorToken = resolveActiveOperatorToken(device);
if (!operatorToken) {
return false;
}
return roleScopesAllow({
role: OPERATOR_ROLE,
requestedScopes: [APPROVALS_SCOPE],
requestedScopes: [APPROVALS_SCOPE, READ_SCOPE],
allowedScopes: operatorToken.scopes,
});
}
@@ -107,7 +110,7 @@ function shouldTargetDevice(params: {
if (!params.requireApprovalScope) {
return true;
}
return canApproveExecRequests(params.device);
return canReceiveExecApprovalRequests(params.device);
}
async function loadRegisteredTargets(params: {
@@ -231,6 +234,7 @@ async function sendRequestedPushes(params: {
plan: DeliveryPlan;
log: GatewayLikeLogger;
}): Promise<{ attempted: number; delivered: number }> {
const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
return await sendApprovalPushes({
approvalId: params.request.id,
plan: params.plan,
@@ -243,12 +247,14 @@ async function sendRequestedPushes(params: {
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
auth: plan.directAuth!,
})
: await sendApnsExecApprovalAlert({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
relayConfig: plan.relayConfig!,
}),
});
@@ -301,6 +307,7 @@ async function sendResolvedPushes(params: {
plan: DeliveryPlan;
log: GatewayLikeLogger;
}): Promise<void> {
const gatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
await sendApprovalPushes({
approvalId: params.approvalId,
plan: params.plan,
@@ -313,12 +320,14 @@ async function sendResolvedPushes(params: {
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
auth: plan.directAuth!,
})
: await sendApnsExecApprovalResolvedWake({
registration: target.registration,
nodeId: target.nodeId,
approvalId,
gatewayDeviceId,
relayConfig: plan.relayConfig!,
}),
});
+2 -2
View File
@@ -8,7 +8,7 @@ import {
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
import {
loadOrCreateDeviceIdentity,
loadOrCreateProcessDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../../infra/device-identity.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
@@ -21,7 +21,7 @@ import type { GatewayRequestHandlers } from "./types.js";
/** Gateway handlers for identity, heartbeat toggles, and system presence events. */
export const systemHandlers: GatewayRequestHandlers = {
"gateway.identity.get": ({ respond }) => {
const identity = loadOrCreateDeviceIdentity();
const identity = loadOrCreateProcessDeviceIdentity();
respond(
true,
{
+1 -1
View File
@@ -9,7 +9,7 @@ export { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
export { agentCommandFromIngress } from "../commands/agent.js";
export { getRuntimeConfig } from "../config/io.js";
export { canonicalizeSessionEntryAliases } from "../config/sessions.js";
export { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
export { loadOrCreateProcessDeviceIdentity } from "../infra/device-identity.js";
export { requestHeartbeat } from "../infra/heartbeat-wake.js";
export { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
export { resolveOutboundTarget } from "../infra/outbound/targets.js";
+3 -3
View File
@@ -46,7 +46,7 @@ const buildSessionLookup = (
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const registerApnsRegistrationMock = vi.hoisted(() => vi.fn());
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
const loadOrCreateProcessDeviceIdentityMock = vi.hoisted(() =>
vi.fn(() => ({
deviceId: "gateway-device-1",
publicKeyPem: "public",
@@ -83,7 +83,7 @@ const runtimeMocks = vi.hoisted(() => ({
enqueueSystemEvent: vi.fn(),
formatForLog: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))),
getRuntimeConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })),
loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
loadOrCreateProcessDeviceIdentity: loadOrCreateProcessDeviceIdentityMock,
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
canonicalizeSessionEntryAliases: vi.fn(),
normalizeChannelId: normalizeChannelIdMock,
@@ -265,7 +265,7 @@ describe("node exec events", () => {
enqueueSystemEventMock.mockReturnValue(true);
requestHeartbeatMock.mockClear();
registerApnsRegistrationVi.mockClear();
loadOrCreateDeviceIdentityMock.mockClear();
loadOrCreateProcessDeviceIdentityMock.mockClear();
normalizeChannelIdVi.mockClear();
normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null);
sanitizeInboundSystemTagsMock.mockClear();
+2 -2
View File
@@ -30,7 +30,7 @@ import {
enqueueSystemEvent,
formatForLog,
getRuntimeConfig,
loadOrCreateDeviceIdentity,
loadOrCreateProcessDeviceIdentity,
loadSessionEntry,
normalizeChannelId,
normalizeMainKey,
@@ -819,7 +819,7 @@ export const handleNodeEvent = async (
try {
if (transport === "relay") {
const gatewayDeviceId = normalizeOptionalString(obj.gatewayDeviceId) ?? "";
const currentGatewayDeviceId = loadOrCreateDeviceIdentity().deviceId;
const currentGatewayDeviceId = loadOrCreateProcessDeviceIdentity().deviceId;
if (!gatewayDeviceId || gatewayDeviceId !== currentGatewayDeviceId) {
ctx.logGateway.warn(
`push relay register rejected node=${nodeId}: gateway identity mismatch`,
+3
View File
@@ -7,6 +7,7 @@ import {
deriveDeviceIdFromPublicKey,
loadDeviceIdentityIfPresent,
loadOrCreateDeviceIdentity,
loadOrCreateProcessDeviceIdentity,
normalizeDevicePublicKeyBase64Url,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
@@ -132,8 +133,10 @@ describe("device identity crypto helpers", () => {
expect(loadDeviceIdentityIfPresent(identityPath)).toBeNull();
const loaded = loadOrCreateDeviceIdentity(identityPath);
const processIdentity = loadOrCreateProcessDeviceIdentity(identityPath);
expect(loaded.deviceId).not.toBe("stale-device-id");
expect(loadOrCreateProcessDeviceIdentity(identityPath)).toBe(processIdentity);
expect(fs.readFileSync(identityPath, "utf8")).toBe(before);
});
});
+17
View File
@@ -264,6 +264,23 @@ export function loadOrCreateDeviceIdentity(
return identity;
}
let processDeviceIdentity: { filePath: string; identity: DeviceIdentity } | undefined;
/**
* Keep one identity stable for the lifetime of the active state-dir process.
* Recognizable invalid stores yield transient keys, so independent reloads would split gateway ownership.
*/
export function loadOrCreateProcessDeviceIdentity(
filePath: string = resolveDefaultIdentityPath(),
): DeviceIdentity {
if (processDeviceIdentity?.filePath === filePath) {
return processDeviceIdentity.identity;
}
const identity = loadOrCreateDeviceIdentity(filePath);
processDeviceIdentity = { filePath, identity };
return identity;
}
/** Load a valid persisted device identity without creating, repairing, or migrating files. */
export function loadDeviceIdentityIfPresent(
filePath: string = resolveDefaultIdentityPath(),
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import type { GatewayConfig } from "../config/types.gateway.js";
import {
loadOrCreateDeviceIdentity,
loadOrCreateProcessDeviceIdentity,
signDevicePayload,
type DeviceIdentity,
} from "./device-identity.js";
@@ -330,7 +330,7 @@ export async function sendApnsRelayPush(params: {
requestSender?: ApnsRelayRequestSender;
}): Promise<ApnsRelayPushResponse> {
const sender = params.requestSender ?? sendApnsRelayRequest;
const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity();
const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateProcessDeviceIdentity();
const signedAtMs = Date.now();
const bodyJson = JSON.stringify({
relayHandle: params.relayHandle,
+6
View File
@@ -498,6 +498,7 @@ describe("push APNs send semantics", () => {
registration,
nodeId: "ios-node-approval-alert",
approvalId: "approval-123",
gatewayDeviceId: "gateway-device-123",
auth,
requestSender: send,
});
@@ -519,6 +520,7 @@ describe("push APNs send semantics", () => {
expectRecordFields(openclawPayload, {
kind: "exec.approval.requested",
approvalId: "approval-123",
gatewayDeviceId: "gateway-device-123",
});
expect(typeof openclawPayload.ts).toBe("number");
expectNoProperties(openclawPayload, [
@@ -548,6 +550,7 @@ describe("push APNs send semantics", () => {
registration,
nodeId: "ios-node-approval-cleanup",
approvalId: "approval-123",
gatewayDeviceId: "gateway-device-123",
auth,
requestSender: send,
});
@@ -563,6 +566,7 @@ describe("push APNs send semantics", () => {
expectRecordFields(openclawPayload, {
kind: "exec.approval.resolved",
approvalId: "approval-123",
gatewayDeviceId: "gateway-device-123",
});
expect(typeof openclawPayload.ts).toBe("number");
expect(result.ok).toBe(true);
@@ -790,6 +794,7 @@ describe("push APNs send semantics", () => {
registration,
nodeId: "ios-node-relay-approval-alert",
approvalId: "approval-relay-1",
gatewayDeviceId: "gateway-device-relay",
relayConfig,
relayGatewayIdentity: gatewayIdentity,
relayRequestSender: send,
@@ -809,6 +814,7 @@ describe("push APNs send semantics", () => {
expectRecordFields(openclawPayload, {
kind: "exec.approval.requested",
approvalId: "approval-relay-1",
gatewayDeviceId: "gateway-device-relay",
});
expect(typeof openclawPayload.ts).toBe("number");
expectNoProperties(openclawPayload, [
+14 -4
View File
@@ -930,7 +930,10 @@ function resolveExecApprovalAlertBody(): string {
return EXEC_APPROVAL_GENERIC_ALERT_BODY;
}
function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: string }): object {
function createExecApprovalAlertPayload(params: {
approvalId: string;
gatewayDeviceId: string;
}): object {
return {
aps: {
alert: {
@@ -944,12 +947,16 @@ function createExecApprovalAlertPayload(params: { nodeId: string; approvalId: st
openclaw: {
kind: "exec.approval.requested",
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
ts: Date.now(),
},
};
}
function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId: string }): object {
function createExecApprovalResolvedPayload(params: {
approvalId: string;
gatewayDeviceId: string;
}): object {
return {
aps: {
"content-available": 1,
@@ -957,6 +964,7 @@ function createExecApprovalResolvedPayload(params: { nodeId: string; approvalId:
openclaw: {
kind: "exec.approval.resolved",
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
ts: Date.now(),
},
};
@@ -1012,6 +1020,7 @@ type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
type ApnsExecApprovalAlertCommonParams = {
nodeId: string;
approvalId: string;
gatewayDeviceId: string;
timeoutMs?: number;
};
@@ -1035,6 +1044,7 @@ type RelayApnsExecApprovalAlertParams = ApnsExecApprovalAlertCommonParams & {
type ApnsExecApprovalResolvedCommonParams = {
nodeId: string;
approvalId: string;
gatewayDeviceId: string;
timeoutMs?: number;
};
@@ -1127,8 +1137,8 @@ export async function sendApnsExecApprovalAlert(
params: DirectApnsExecApprovalAlertParams | RelayApnsExecApprovalAlertParams,
): Promise<ApnsPushAlertResult> {
const payload = createExecApprovalAlertPayload({
nodeId: params.nodeId,
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
});
if (params.registration.transport === "relay") {
@@ -1160,8 +1170,8 @@ export async function sendApnsExecApprovalResolvedWake(
params: DirectApnsExecApprovalResolvedParams | RelayApnsExecApprovalResolvedParams,
): Promise<ApnsPushWakeResult> {
const payload = createExecApprovalResolvedPayload({
nodeId: params.nodeId,
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
});
if (params.registration.transport === "relay") {