iOS Security Stack 3/5: Runtime Security Guards (#33031)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 9917165401
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-03-03 16:30:27 +00:00
committed by GitHub
parent 6df57d9633
commit a3112d6c5f
4 changed files with 102 additions and 13 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.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.

View File

@@ -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<Void, Never>?
// 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)

View File

@@ -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)

View File

@@ -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)