Files
openclaw/apps/macos/Sources/OpenClaw/DeepLinks.swift
2026-02-18 04:40:42 +01:00

200 lines
7.5 KiB
Swift

import AppKit
import Foundation
import OpenClawKit
import OSLog
import Security
private let deepLinkLogger = Logger(subsystem: "ai.openclaw", category: "DeepLink")
enum DeepLinkAgentPolicy {
static let maxMessageChars = 20000
static let maxUnkeyedConfirmChars = 240
enum ValidationError: Error, Equatable, LocalizedError {
case messageTooLongForConfirmation(max: Int, actual: Int)
var errorDescription: String? {
switch self {
case let .messageTooLongForConfirmation(max, actual):
"Message is too long to confirm safely (\(actual) chars; max \(max) without key)."
}
}
}
static func validateMessageForHandle(message: String, allowUnattended: Bool) -> Result<Void, ValidationError> {
if !allowUnattended, message.count > self.maxUnkeyedConfirmChars {
return .failure(.messageTooLongForConfirmation(max: self.maxUnkeyedConfirmChars, actual: message.count))
}
return .success(())
}
static func effectiveDelivery(
link: AgentDeepLink,
allowUnattended: Bool) -> (deliver: Bool, to: String?, channel: GatewayAgentChannel)
{
if !allowUnattended {
// Without the unattended key, ignore delivery/routing knobs to reduce exfiltration risk.
return (deliver: false, to: nil, channel: .last)
}
let channel = GatewayAgentChannel(raw: link.channel)
let deliver = channel.shouldDeliver(link.deliver)
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
return (deliver: deliver, to: to, channel: channel)
}
}
@MainActor
final class DeepLinkHandler {
static let shared = DeepLinkHandler()
private var lastPromptAt: Date = .distantPast
/// Ephemeral, in-memory key used for unattended deep links originating from the in-app Canvas.
/// This avoids blocking Canvas init on UserDefaults and doesn't weaken the external deep-link prompt:
/// outside callers can't know this randomly generated key.
private nonisolated static let canvasUnattendedKey: String = DeepLinkHandler.generateRandomKey()
func handle(url: URL) async {
guard let route = DeepLinkParser.parse(url) else {
deepLinkLogger.debug("ignored url \(url.absoluteString, privacy: .public)")
return
}
guard !AppStateStore.shared.isPaused else {
self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.")
return
}
switch route {
case let .agent(link):
await self.handleAgent(link: link, originalURL: url)
case .gateway:
break
}
}
private func handleAgent(link: AgentDeepLink, originalURL: URL) async {
let messagePreview = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
if messagePreview.count > DeepLinkAgentPolicy.maxMessageChars {
self.presentAlert(title: "Deep link too large", message: "Message exceeds 20,000 characters.")
return
}
let allowUnattended = link.key == Self.canvasUnattendedKey || link.key == Self.expectedKey()
if !allowUnattended {
if Date().timeIntervalSince(self.lastPromptAt) < 1.0 {
deepLinkLogger.debug("throttling deep link prompt")
return
}
self.lastPromptAt = Date()
if case let .failure(error) = DeepLinkAgentPolicy.validateMessageForHandle(
message: messagePreview,
allowUnattended: allowUnattended)
{
self.presentAlert(title: "Deep link blocked", message: error.localizedDescription)
return
}
let urlText = originalURL.absoluteString
let urlPreview = urlText.count > 500 ? "\(urlText.prefix(500))" : urlText
let body =
"Run the agent with this message?\n\n\(messagePreview)\n\nURL:\n\(urlPreview)"
guard self.confirm(title: "Run OpenClaw agent?", message: body) else { return }
}
if AppStateStore.shared.connectionMode == .local {
GatewayProcessManager.shared.setActive(true)
}
do {
let effectiveDelivery = DeepLinkAgentPolicy.effectiveDelivery(link: link, allowUnattended: allowUnattended)
let explicitSessionKey = link.sessionKey?
.trimmingCharacters(in: .whitespacesAndNewlines)
.nonEmpty
let resolvedSessionKey: String = if let explicitSessionKey {
explicitSessionKey
} else {
await GatewayConnection.shared.mainSessionKey()
}
let invocation = GatewayAgentInvocation(
message: messagePreview,
sessionKey: resolvedSessionKey,
thinking: link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty,
deliver: effectiveDelivery.deliver,
to: effectiveDelivery.to,
channel: effectiveDelivery.channel,
timeoutSeconds: link.timeoutSeconds,
idempotencyKey: UUID().uuidString)
let res = await GatewayConnection.shared.sendAgent(invocation)
if !res.ok {
throw NSError(
domain: "DeepLink",
code: 1,
userInfo: [NSLocalizedDescriptionKey: res.error ?? "agent request failed"])
}
} catch {
self.presentAlert(title: "Agent request failed", message: error.localizedDescription)
}
}
// MARK: - Auth
static func currentKey() -> String {
self.expectedKey()
}
static func currentCanvasKey() -> String {
self.canvasUnattendedKey
}
private static func expectedKey() -> String {
let defaults = UserDefaults.standard
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
return key
}
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let data = Data(bytes)
let key = data
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
defaults.set(key, forKey: deepLinkKeyKey)
return key
}
private nonisolated static func generateRandomKey() -> 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: "")
}
// MARK: - UI
private func confirm(title: String, message: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "Run")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert.runModal() == .alertFirstButtonReturn
}
private func presentAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "OK")
alert.alertStyle = .informational
alert.runModal()
}
}