Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
213 lines
7.9 KiB
Swift
213 lines
7.9 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
|
|
}
|
|
switch route {
|
|
case .dashboard:
|
|
await self.openDashboard()
|
|
return
|
|
case let .agent(link):
|
|
guard !AppStateStore.shared.isPaused else {
|
|
self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.")
|
|
return
|
|
}
|
|
await self.handleAgent(link: link, originalURL: url)
|
|
case .gateway:
|
|
guard !AppStateStore.shared.isPaused else {
|
|
self.presentAlert(title: "OpenClaw is paused", message: "Unpause OpenClaw to run agent actions.")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
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 openDashboard() async {
|
|
do {
|
|
try await DashboardManager.shared.show()
|
|
} catch {
|
|
DashboardManager.shared.showFailure(error)
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|