diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d514f87989..d59aa855221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman. - iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky. - iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky. +- iOS/Security guardrails: limit production API-key sourcing to app config and make deep-link confirmation prompts safer by coalescing queued requests instead of silently dropping them. (#33031) thanks @mbelinky. - Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin. - Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee. - Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras. diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index ca9c3f9d0c3..54548eb8d96 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -90,7 +90,9 @@ final class NodeAppModel { var lastShareEventText: String = "No share events yet." var openChatRequestID: Int = 0 private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt? private var lastAgentDeepLinkPromptAt: Date = .distantPast + @ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task? // Primary "node" connection: used for device capabilities and node.invoke requests. private let nodeGateway = GatewayNodeSession() @@ -2591,19 +2593,31 @@ extension NodeAppModel { "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)") return } - if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 { - self.deepLinkLogger.debug("agent deep link prompt throttled") - return - } - self.lastAgentDeepLinkPromptAt = Date() - let urlText = originalURL.absoluteString let prompt = AgentDeepLinkPrompt( id: UUID().uuidString, messagePreview: message, urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText, request: self.effectiveAgentDeepLinkForPrompt(link)) - self.pendingAgentDeepLinkPrompt = prompt + + let promptIntervalSeconds = 5.0 + let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) + if elapsed < promptIntervalSeconds { + if self.pendingAgentDeepLinkPrompt != nil { + self.pendingAgentDeepLinkPrompt = prompt + self.recordShareEvent("Updated local confirmation request (\(message.count) chars).") + self.deepLinkLogger.debug("agent deep link prompt coalesced into active confirmation") + return + } + + let remaining = max(0, promptIntervalSeconds - elapsed) + self.queueAgentDeepLinkPrompt(prompt, initialDelaySeconds: remaining) + self.recordShareEvent("Queued local confirmation (\(message.count) chars).") + self.deepLinkLogger.debug("agent deep link prompt queued due to rate limit") + return + } + + self.presentAgentDeepLinkPrompt(prompt) self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).") self.deepLinkLogger.info("agent deep link requires local confirmation") return @@ -2672,6 +2686,60 @@ extension NodeAppModel { self.deepLinkLogger.info("agent deep link cancelled by local user") } + private func presentAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt) { + self.lastAgentDeepLinkPromptAt = Date() + self.pendingAgentDeepLinkPrompt = prompt + } + + private func queueAgentDeepLinkPrompt(_ prompt: AgentDeepLinkPrompt, initialDelaySeconds: TimeInterval) { + self.queuedAgentDeepLinkPrompt = prompt + guard self.queuedAgentDeepLinkPromptTask == nil else { return } + + self.queuedAgentDeepLinkPromptTask = Task { [weak self] in + guard let self else { return } + let delayNs = UInt64(max(0, initialDelaySeconds) * 1_000_000_000) + if delayNs > 0 { + do { + try await Task.sleep(nanoseconds: delayNs) + } catch { + return + } + } + await self.deliverQueuedAgentDeepLinkPrompt() + } + } + + private func deliverQueuedAgentDeepLinkPrompt() async { + defer { self.queuedAgentDeepLinkPromptTask = nil } + let promptIntervalSeconds = 5.0 + while let prompt = self.queuedAgentDeepLinkPrompt { + if self.pendingAgentDeepLinkPrompt != nil { + do { + try await Task.sleep(nanoseconds: 200_000_000) + } catch { + return + } + continue + } + + let elapsed = Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) + if elapsed < promptIntervalSeconds { + let remaining = max(0, promptIntervalSeconds - elapsed) + do { + try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } catch { + return + } + continue + } + + self.queuedAgentDeepLinkPrompt = nil + self.presentAgentDeepLinkPrompt(prompt) + self.recordShareEvent("Awaiting local confirmation (\(prompt.messagePreview.count) chars).") + self.deepLinkLogger.info("agent deep link queued prompt delivered") + } + } + private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async { do { try await self.sendAgentRequest(link: link) diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 5210921a5a7..859c9e43566 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -987,9 +987,12 @@ final class TalkModeManager: NSObject { self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)") } - let resolvedKey = - (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? - ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] + let configuredKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil + #if DEBUG + let resolvedKey = configuredKey ?? ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] + #else + let resolvedKey = configuredKey + #endif let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines) let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId let voiceId: String? = if let apiKey, !apiKey.isEmpty { @@ -1483,9 +1486,12 @@ final class TalkModeManager: NSObject { "talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)") } - let resolvedKey = - (self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ?? - ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] + let configuredKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil + #if DEBUG + let resolvedKey = configuredKey ?? ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"] + #else + let resolvedKey = configuredKey + #endif let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines) let voiceId: String? = if let apiKey, !apiKey.isEmpty { await self.resolveVoiceId(preferred: preferredVoice, apiKey: apiKey) diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index c12c9727874..2875fa31339 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -416,6 +416,20 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer #expect(appModel.openChatRequestID == 1) } + @Test @MainActor func handleDeepLinkCoalescesPromptWhenRateLimited() async throws { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + + await appModel.handleDeepLink(url: makeAgentDeepLinkURL(message: "first prompt")) + let firstPrompt = try #require(appModel.pendingAgentDeepLinkPrompt) + + await appModel.handleDeepLink(url: makeAgentDeepLinkURL(message: "second prompt")) + let coalescedPrompt = try #require(appModel.pendingAgentDeepLinkPrompt) + + #expect(coalescedPrompt.id != firstPrompt.id) + #expect(coalescedPrompt.messagePreview.contains("second prompt")) + } + @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { let appModel = NodeAppModel() appModel._test_setGatewayConnected(true)