mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(ios): gate agent deep links with local confirmation
This commit is contained in:
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting.
|
||||
- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting.
|
||||
- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting.
|
||||
- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung.
|
||||
- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras.
|
||||
- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting.
|
||||
|
||||
40
apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift
Normal file
40
apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DeepLinkAgentPromptAlert: ViewModifier {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
|
||||
private var promptBinding: Binding<NodeAppModel.AgentDeepLinkPrompt?> {
|
||||
Binding(
|
||||
get: { self.appModel.pendingAgentDeepLinkPrompt },
|
||||
set: { _ in
|
||||
// Keep prompt state until explicit user action.
|
||||
})
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.alert(item: self.promptBinding) { prompt in
|
||||
Alert(
|
||||
title: Text("Run OpenClaw agent?"),
|
||||
message: Text(
|
||||
"""
|
||||
Message:
|
||||
\(prompt.messagePreview)
|
||||
|
||||
URL:
|
||||
\(prompt.urlPreview)
|
||||
"""),
|
||||
primaryButton: .cancel(Text("Cancel")) {
|
||||
self.appModel.declinePendingAgentDeepLinkPrompt()
|
||||
},
|
||||
secondaryButton: .default(Text("Run")) {
|
||||
Task { await self.appModel.approvePendingAgentDeepLinkPrompt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func deepLinkAgentPromptAlert() -> some View {
|
||||
self.modifier(DeepLinkAgentPromptAlert())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Observation
|
||||
import os
|
||||
import Security
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
@@ -37,9 +38,22 @@ private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
cont?.resume(returning: response)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IOSDeepLinkAgentPolicy {
|
||||
static let maxMessageChars = 20000
|
||||
static let maxUnkeyedConfirmChars = 240
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NodeAppModel {
|
||||
struct AgentDeepLinkPrompt: Identifiable, Equatable {
|
||||
let id: String
|
||||
let messagePreview: String
|
||||
let urlPreview: String
|
||||
let request: AgentDeepLink
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
@@ -74,6 +88,8 @@ final class NodeAppModel {
|
||||
var gatewayAgents: [AgentSummary] = []
|
||||
var lastShareEventText: String = "No share events yet."
|
||||
var openChatRequestID: Int = 0
|
||||
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||
|
||||
// Primary "node" connection: used for device capabilities and node.invoke requests.
|
||||
private let nodeGateway = GatewayNodeSession()
|
||||
@@ -485,21 +501,14 @@ final class NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func applyMainSessionKey(_ key: String?) {
|
||||
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed == current { return }
|
||||
self.mainSessionBaseKey = trimmed
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
|
||||
var seamColor: Color {
|
||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||
}
|
||||
|
||||
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
|
||||
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
||||
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
||||
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
||||
private static var apnsEnvironment: String {
|
||||
#if DEBUG
|
||||
"sandbox"
|
||||
@@ -508,17 +517,6 @@ final class NodeAppModel {
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
private func refreshBrandingFromGateway() async {
|
||||
do {
|
||||
let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
@@ -699,117 +697,6 @@ final class NodeAppModel {
|
||||
self.gatewayHealthMonitor.stop()
|
||||
}
|
||||
|
||||
private func refreshWakeWordsFromGateway() async {
|
||||
do {
|
||||
let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let lower = gatewayError.message.lowercased()
|
||||
if lower.contains("unauthorized role") || lower.contains("missing scope") {
|
||||
await self.setGatewayHealthMonitorDisabled(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private func isGatewayHealthMonitorDisabled() -> Bool {
|
||||
self.gatewayHealthMonitorDisabled
|
||||
}
|
||||
|
||||
private func setGatewayHealthMonitorDisabled(_ disabled: Bool) {
|
||||
self.gatewayHealthMonitorDisabled = disabled
|
||||
}
|
||||
|
||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||
if await !self.isGatewayConnected() {
|
||||
throw NSError(domain: "Gateway", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway not connected",
|
||||
])
|
||||
}
|
||||
struct Payload: Codable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
}
|
||||
let payload = Payload(text: text, sessionKey: sessionKey)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
|
||||
])
|
||||
}
|
||||
await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
}
|
||||
|
||||
func handleDeepLink(url: URL) async {
|
||||
guard let route = DeepLinkParser.parse(url) else { return }
|
||||
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgentDeepLink(link, originalURL: url)
|
||||
case .gateway:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
self.deepLinkLogger.info(
|
||||
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)"
|
||||
)
|
||||
|
||||
if message.count > 20000 {
|
||||
self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)."
|
||||
self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
|
||||
return
|
||||
}
|
||||
|
||||
guard await self.isGatewayConnected() else {
|
||||
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
||||
self.recordShareEvent("Failed: gateway not connected.")
|
||||
self.deepLinkLogger.error("agent deep link rejected: gateway not connected")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
self.screen.errorText = nil
|
||||
self.recordShareEvent("Sent to gateway (\(message.count) chars).")
|
||||
self.deepLinkLogger.info("agent deep link forwarded to gateway")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
|
||||
self.recordShareEvent("Failed: \(error.localizedDescription)")
|
||||
self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAgentRequest(link: AgentDeepLink) async throws {
|
||||
if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
throw NSError(domain: "DeepLink", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "invalid agent message",
|
||||
])
|
||||
}
|
||||
|
||||
// iOS gateway forwards to the gateway; no local auth prompts here.
|
||||
// (Key-based unattended auth is handled on macOS for openclaw:// links.)
|
||||
let data = try JSONEncoder().encode(link)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
||||
])
|
||||
}
|
||||
await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
}
|
||||
|
||||
private func isGatewayConnected() async -> Bool {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
let command = req.command
|
||||
|
||||
@@ -2560,6 +2447,229 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
private func refreshWakeWordsFromGateway() async {
|
||||
do {
|
||||
let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
} catch {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let lower = gatewayError.message.lowercased()
|
||||
if lower.contains("unauthorized role") || lower.contains("missing scope") {
|
||||
await self.setGatewayHealthMonitorDisabled(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private func isGatewayHealthMonitorDisabled() -> Bool {
|
||||
self.gatewayHealthMonitorDisabled
|
||||
}
|
||||
|
||||
private func setGatewayHealthMonitorDisabled(_ disabled: Bool) {
|
||||
self.gatewayHealthMonitorDisabled = disabled
|
||||
}
|
||||
|
||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||
if await !self.isGatewayConnected() {
|
||||
throw NSError(domain: "Gateway", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway not connected",
|
||||
])
|
||||
}
|
||||
struct Payload: Codable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
}
|
||||
let payload = Payload(text: text, sessionKey: sessionKey)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8",
|
||||
])
|
||||
}
|
||||
await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json)
|
||||
}
|
||||
|
||||
func handleDeepLink(url: URL) async {
|
||||
guard let route = DeepLinkParser.parse(url) else { return }
|
||||
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgentDeepLink(link, originalURL: url)
|
||||
case .gateway:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async {
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
self.deepLinkLogger.info(
|
||||
"agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)"
|
||||
)
|
||||
|
||||
if message.count > IOSDeepLinkAgentPolicy.maxMessageChars {
|
||||
self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)."
|
||||
self.recordShareEvent("Rejected: message too large (\(message.count) chars).")
|
||||
return
|
||||
}
|
||||
|
||||
guard await self.isGatewayConnected() else {
|
||||
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
||||
self.recordShareEvent("Failed: gateway not connected.")
|
||||
self.deepLinkLogger.error("agent deep link rejected: gateway not connected")
|
||||
return
|
||||
}
|
||||
|
||||
let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key)
|
||||
if !allowUnattended {
|
||||
if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars {
|
||||
self.screen.errorText = "Deep link blocked (message too long without key)."
|
||||
self.recordShareEvent(
|
||||
"Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.")
|
||||
self.deepLinkLogger.error(
|
||||
"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
|
||||
self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).")
|
||||
self.deepLinkLogger.info("agent deep link requires local confirmation")
|
||||
return
|
||||
}
|
||||
|
||||
await self.submitAgentDeepLink(link, messageCharCount: message.count)
|
||||
}
|
||||
|
||||
private func sendAgentRequest(link: AgentDeepLink) async throws {
|
||||
if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
throw NSError(domain: "DeepLink", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "invalid agent message",
|
||||
])
|
||||
}
|
||||
|
||||
let data = try JSONEncoder().encode(link)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "NodeAppModel", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8",
|
||||
])
|
||||
}
|
||||
await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json)
|
||||
}
|
||||
|
||||
private func isGatewayConnected() async -> Bool {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
private func applyMainSessionKey(_ key: String?) {
|
||||
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed == current { return }
|
||||
self.mainSessionBaseKey = trimmed
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
}
|
||||
|
||||
private static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
|
||||
func approvePendingAgentDeepLinkPrompt() async {
|
||||
guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
|
||||
self.pendingAgentDeepLinkPrompt = nil
|
||||
guard await self.isGatewayConnected() else {
|
||||
self.screen.errorText = "Gateway not connected (cannot forward deep link)."
|
||||
self.recordShareEvent("Failed: gateway not connected.")
|
||||
self.deepLinkLogger.error("agent deep link approval failed: gateway not connected")
|
||||
return
|
||||
}
|
||||
await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count)
|
||||
}
|
||||
|
||||
func declinePendingAgentDeepLinkPrompt() {
|
||||
guard self.pendingAgentDeepLinkPrompt != nil else { return }
|
||||
self.pendingAgentDeepLinkPrompt = nil
|
||||
self.screen.errorText = "Deep link cancelled."
|
||||
self.recordShareEvent("Cancelled: deep link confirmation declined.")
|
||||
self.deepLinkLogger.info("agent deep link cancelled by local user")
|
||||
}
|
||||
|
||||
private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async {
|
||||
do {
|
||||
try await self.sendAgentRequest(link: link)
|
||||
self.screen.errorText = nil
|
||||
self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).")
|
||||
self.deepLinkLogger.info("agent deep link forwarded to gateway")
|
||||
self.openChatRequestID &+= 1
|
||||
} catch {
|
||||
self.screen.errorText = "Agent request failed: \(error.localizedDescription)"
|
||||
self.recordShareEvent("Failed: \(error.localizedDescription)")
|
||||
self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink {
|
||||
// Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk.
|
||||
AgentDeepLink(
|
||||
message: link.message,
|
||||
sessionKey: link.sessionKey,
|
||||
thinking: link.thinking,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: link.timeoutSeconds,
|
||||
key: link.key)
|
||||
}
|
||||
|
||||
private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool {
|
||||
let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !normalizedKey.isEmpty else { return false }
|
||||
return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
private static func expectedDeepLinkKey() -> String {
|
||||
let defaults = UserDefaults.standard
|
||||
if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty {
|
||||
return key
|
||||
}
|
||||
let key = self.generateDeepLinkKey()
|
||||
defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey)
|
||||
return key
|
||||
}
|
||||
|
||||
private static func generateDeepLinkKey() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
let data = Data(bytes)
|
||||
return data
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async {
|
||||
await self.handleWatchQuickReply(event)
|
||||
@@ -2607,5 +2717,13 @@ extension NodeAppModel {
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.queuedWatchReplies.count
|
||||
}
|
||||
|
||||
func _test_setGatewayConnected(_ connected: Bool) {
|
||||
self.gatewayConnected = connected
|
||||
}
|
||||
|
||||
static func _test_currentDeepLinkKey() -> String {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -88,6 +88,7 @@ struct RootCanvas: View {
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.deepLinkAgentPromptAlert()
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
|
||||
@@ -29,8 +29,35 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
return try body()
|
||||
}
|
||||
|
||||
private func makeAgentDeepLinkURL(
|
||||
message: String,
|
||||
deliver: Bool = false,
|
||||
to: String? = nil,
|
||||
channel: String? = nil,
|
||||
key: String? = nil) -> URL
|
||||
{
|
||||
var components = URLComponents()
|
||||
components.scheme = "openclaw"
|
||||
components.host = "agent"
|
||||
var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)]
|
||||
if deliver {
|
||||
queryItems.append(URLQueryItem(name: "deliver", value: "1"))
|
||||
}
|
||||
if let to {
|
||||
queryItems.append(URLQueryItem(name: "to", value: to))
|
||||
}
|
||||
if let channel {
|
||||
queryItems.append(URLQueryItem(name: "channel", value: channel))
|
||||
}
|
||||
if let key {
|
||||
queryItems.append(URLQueryItem(name: "key", value: key))
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
return components.url!
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockWatchMessagingService: WatchMessagingServicing, @unchecked Sendable {
|
||||
private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable {
|
||||
var currentStatus = WatchMessagingStatus(
|
||||
supported: true,
|
||||
paired: true,
|
||||
@@ -327,6 +354,58 @@ private final class MockWatchMessagingService: WatchMessagingServicing, @uncheck
|
||||
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_setGatewayConnected(true)
|
||||
let url = makeAgentDeepLinkURL(message: "hello from deep link")
|
||||
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.pendingAgentDeepLinkPrompt != nil)
|
||||
#expect(appModel.openChatRequestID == 0)
|
||||
|
||||
await appModel.approvePendingAgentDeepLinkPrompt()
|
||||
#expect(appModel.pendingAgentDeepLinkPrompt == nil)
|
||||
#expect(appModel.openChatRequestID == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_setGatewayConnected(true)
|
||||
let url = makeAgentDeepLinkURL(
|
||||
message: "route this",
|
||||
deliver: true,
|
||||
to: "123456",
|
||||
channel: "telegram")
|
||||
|
||||
await appModel.handleDeepLink(url: url)
|
||||
let prompt = try #require(appModel.pendingAgentDeepLinkPrompt)
|
||||
#expect(prompt.request.deliver == false)
|
||||
#expect(prompt.request.to == nil)
|
||||
#expect(prompt.request.channel == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_setGatewayConnected(true)
|
||||
let message = String(repeating: "x", count: 241)
|
||||
let url = makeAgentDeepLinkURL(message: message)
|
||||
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.pendingAgentDeepLinkPrompt == nil)
|
||||
#expect(appModel.screen.errorText?.contains("blocked") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_setGatewayConnected(true)
|
||||
let key = NodeAppModel._test_currentDeepLinkKey()
|
||||
let url = makeAgentDeepLinkURL(message: "trusted request", key: key)
|
||||
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.pendingAgentDeepLinkPrompt == nil)
|
||||
#expect(appModel.openChatRequestID == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
|
||||
let appModel = NodeAppModel()
|
||||
await #expect(throws: Error.self) {
|
||||
|
||||
Reference in New Issue
Block a user