diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxApprovalAnswerer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxApprovalAnswerer.swift new file mode 100644 index 000000000000..4a39c8cd512d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxApprovalAnswerer.swift @@ -0,0 +1,49 @@ +import Foundation +import os + +/// The app's ``AgentApprovalAnswering``: each read or answer is one short person-proven +/// connection to this app's acpmux (``AcpmuxPersonLine``). An answer re-reads the prompt and +/// counts only while the same request (``AcpmuxPermissionPrompt/digest(_:)``) is pending and +/// offers the option the decision maps to (``AcpmuxPermissionPrompt/option(allow:offered:)``, +/// the feed bridge's own mapping). Then `_acpmux/permission_respond`, as the chat's own Allow +/// and Deny send it. +@MainActor +public final class AcpmuxApprovalAnswerer: AgentApprovalAnswering { + private let environment: AcpmuxEnvironment + private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "notifications.approval") + + public init(environment: AcpmuxEnvironment) { + self.environment = environment + } + + public func pendingApproval(session: String) async -> AgentApproval? { + guard let line = await AcpmuxPersonLine.open(environment) else { return nil } + defer { line.close() } + let pending = await line.pending(of: session) + // One prompt only: with several, a banner could not say which one its buttons answer. + guard pending.count == 1, let permission = pending[0]["permissionId"] as? String, + let request = pending[0]["request"] as? [String: Any], AcpmuxPermissionPrompt.isToolPermission(request) + else { return nil } + return AgentApproval(session: session, permission: permission, digest: AcpmuxPermissionPrompt.digest(request), + summary: AcpmuxPermissionPrompt.summary(request)) + } + + public func answer(_ approval: AgentApproval, allow: Bool) async -> Bool { + guard let line = await AcpmuxPersonLine.open(environment) else { return false } + defer { line.close() } + // acpmux refuses an allow from a connection that is not the person. + if allow, !line.isPerson { + logger.error("approval banner: acpmux refused this app's person proof; nothing allowed") + return false + } + guard let now = await line.pending(of: approval.session).first(where: { $0["permissionId"] as? String == approval.permission }), + let request = now["request"] as? [String: Any], AcpmuxPermissionPrompt.digest(request) == approval.digest, + let option = AcpmuxPermissionPrompt.option(allow: allow, offered: request["options"] as? [[String: Any]] ?? []) + else { + logger.info("approval banner: the prompt ended or changed since its banner posted; nothing answered") + return false + } + let params: [String: Any] = ["sessionId": approval.session, "permissionId": approval.permission, "optionId": option] + return await line.call("_acpmux/permission_respond", params) != nil + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift new file mode 100644 index 000000000000..102f58ca3766 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift @@ -0,0 +1,120 @@ +import Foundation +import CryptoKit + +/// An acpmux permission prompt as the app reads and answers it for the person (cx-aocz, cx-kz6m0.2): +/// the feed bridge (an answer from the phone) and approval banners share this. Allow maps to the +/// offered `allow_once` option, deny to the offered reject option; an option kind offered twice maps +/// to nothing. Shown text is scrubbed of secret shapes and shows invisible characters. +nonisolated enum AcpmuxPermissionPrompt { + /// One JSON-RPC request line. + static func frame(id: Int, method: String, params: [String: Any]) -> Data { + let request: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method, "params": params] + var data = (try? JSONSerialization.data(withJSONObject: request)) ?? Data() + data.append(0x0A) + return data + } + + /// sha256 of the request's canonical JSON (sorted keys). + static func digest(_ request: [String: Any]) -> String { + let data = (try? JSONSerialization.data(withJSONObject: request, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data() + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + /// The command a tool input runs: a string, or an argv array joined as a shell would read it + /// (`["bash", "-lc", "rm -rf ~"]` shows as `bash -lc 'rm -rf ~'`). An array with other values + /// shows as compact JSON. + static func shownCommand(_ value: Any?) -> String? { + if let text = value as? String { return text } + guard let parts = value as? [Any] else { return value.map(compactJSON) } + guard let words = parts as? [String] else { return compactJSON(parts) } + return words.map(shellQuoted).joined(separator: " ") + } + + static func shellQuoted(_ word: String) -> String { + let plain = !word.isEmpty && word.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.contains($0) && $0.isASCII || "-_./=:,+@%".unicodeScalars.contains($0) + } + return plain ? word : "'" + word.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func compactJSON(_ value: Any) -> String { + let data = (try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed])) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + /// Bidi controls and zero-width characters shown as ``, so a command cannot read + /// one way and run another. + static func visible(_ text: String) -> String { + var out = "" + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x061C, 0x180E, 0x200B...0x200F, 0x202A...0x202E, 0x2060...0x2064, 0x2066...0x2069, 0xFEFF: + out += String(format: "", scalar.value) + default: + out.unicodeScalars.append(scalar) + } + } + return out + } + + /// A short, scrubbed summary of a tool's input besides its command: field names with their + /// values (paths, patterns; other values as compact JSON), `cwd` first whenever present. + /// `env` is never shown, and its presence marks the summary truncated. `truncated`: a field + /// beyond the first 8, a long name, a long value, or `env` (the phone then never allows). + static func inputSummary(_ input: [String: Any]) -> (fields: [String: String], truncated: Bool)? { + var out: [String: String] = [:] + let env = input.first { $0.key.lowercased() == "env" } + var truncated = env.map { !(($0.value as? [String: Any])?.isEmpty ?? false) } ?? false + let shown = input.filter { $0.key.lowercased() != "env" && $0.key != "command" } + .sorted { ($0.key == "cwd" ? 0 : 1, $0.key) < ($1.key == "cwd" ? 0 : 1, $1.key) } + if shown.count > 8 { truncated = true } + for (key, value) in shown.prefix(8) { + let raw = value as? String ?? compactJSON(value) + let scrubbed = FeedSecretScrubber.scrub(raw) + // A redaction must never hide what the person signs. + if scrubbed != raw { truncated = true } + let text = visible(scrubbed) + if key.count > 40 || text.count > 200 { truncated = true } + out[cut(visible(key), 40)] = cut(text, 200) + } + // Two shown names that read the same after the cut collide: one value is not shown. + if out.count < min(shown.count, 8) { truncated = true } + return out.isEmpty && !truncated ? nil : (out, truncated) + } + + static func cut(_ text: String, _ limit: Int) -> String { + text.count > limit ? String(text.prefix(limit - 1)) + "…" : text + } + + static func option(allow: Bool, offered: [[String: Any]]) -> String? { + let kinds = allow ? ["allow_once"] : ["reject_once", "reject_always"] + for kind in kinds { + let matches = offered.filter { $0["kind"] as? String == kind } + if matches.count == 1, let id = matches[0]["optionId"] as? String { return id } + } + return nil + } + + /// A tool permission both buttons can answer: not a question (the daemon's normalized + /// `toolCall._meta.acpmux.question`), and it offers allow once and a deny. + static func isToolPermission(_ request: [String: Any]) -> Bool { + let tool = request["toolCall"] as? [String: Any] + let acpmux = (tool?["_meta"] as? [String: Any])?["acpmux"] as? [String: Any] + let offered = request["options"] as? [[String: Any]] ?? [] + return acpmux?["question"] == nil && option(allow: true, offered: offered) != nil + && option(allow: false, offered: offered) != nil + } + + /// What the prompt asks to do, for a banner: its tool call's title, then the command it runs, + /// secrets scrubbed and invisible characters shown (as the feed bridge shows them). + static func summary(_ request: [String: Any]) -> String { + let call = request["toolCall"] as? [String: Any] ?? [:] + let input = call["rawInput"] as? [String: Any] ?? [:] + let lines = [call["title"] as? String, shownCommand(input["command"])] + .compactMap { $0.map { visible(FeedSecretScrubber.scrub($0)) } } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let unique = lines.count == 2 && lines[0] == lines[1] ? [lines[0]] : lines + return cut(unique.joined(separator: "\n"), 300) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPersonLine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPersonLine.swift new file mode 100644 index 000000000000..b106af97715f --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPersonLine.swift @@ -0,0 +1,90 @@ +import CmuxNextWakeups +import Foundation + +/// One short connection to this app's acpmux for a few requests, proven as the person the way +/// the feed bridge's connection is (acpmux `hub/person.rs`: `initialize`, then +/// `_acpmux/person_prove` for its challenge as the second request). The server peer is checked +/// before the first byte (``AcpmuxCheckedLine``). The whole connection lives at most `deadline`; +/// every request still waiting then answers nil. +@MainActor +final class AcpmuxPersonLine { + private let line: AcpmuxCheckedLine + private let timer = DemandTimer(owner: "acpmux.person-line") + private var nextID = 10 + private var waiters: [Int: CheckedContinuation] = [:] + private var closed = false + /// acpmux took the person proof: this connection may allow. + private(set) var isPerson = false + + private init(environment: AcpmuxEnvironment) { + line = AcpmuxCheckedLine(environment: environment) + } + + /// Connects, initializes and proves the person; nil when no acpmux answers. A connection + /// whose proof was refused still reads and denies (``isPerson`` is false). + static func open(_ environment: AcpmuxEnvironment, deadline: Duration = .seconds(5)) async -> AcpmuxPersonLine? { + guard FileManager.default.fileExists(atPath: environment.socketPath) else { return nil } + let connection = AcpmuxPersonLine(environment: environment) + connection.timer.schedule(after: deadline) { @MainActor [weak connection] in connection?.close() } + guard let initialized = await connection.start() else { + connection.close() + return nil + } + let challenge = ((initialized["_meta"] as? [String: Any])?["acpmux"] as? [String: Any])?["personChallenge"] as? [String: Any] + if let nonce = challenge?["nonce"] as? String, let id = challenge?["connection"] as? String, + let proof = connection.line.personProof(nonce: nonce, connection: id) { + connection.isPerson = (await connection.call("_acpmux/person_prove", ["proof": proof]))?["person"] as? Bool ?? false + } + return connection + } + + /// One request; its `result`, or nil when it failed or the connection closed. + func call(_ method: String, _ params: [String: Any]) async -> [String: Any]? { + guard !closed else { return nil } + nextID += 1 + let id = nextID + let frame = AcpmuxPermissionPrompt.frame(id: id, method: method, params: params) + let reply: Data? = await withCheckedContinuation { waiter in + waiters[id] = waiter + line.send(frame) + } + return reply.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } + } + + /// The pending prompts of `session` (`_acpmux/info`). + func pending(of session: String) async -> [[String: Any]] { + (await call("_acpmux/info", ["sessionId": session]))?["pending"] as? [[String: Any]] ?? [] + } + + func close() { + guard !closed else { return } + closed = true + timer.cancel() + line.cancel() + for (_, waiter) in waiters { waiter.resume(returning: nil) } + waiters = [:] + } + + /// Sends `initialize` as the first request and waits for its result. + private func start() async -> [String: Any]? { + let frame = AcpmuxPermissionPrompt.frame(id: 1, method: "initialize", params: [ + "protocolVersion": 1, "clientInfo": ["name": "cmux-next-approval-banner", "version": "1"], + "clientCapabilities": [String: Any](), + ]) + let reply: Data? = await withCheckedContinuation { waiter in + waiters[1] = waiter + line.start(send: frame, + onLine: { [weak self] data in Task { @MainActor in self?.handle(data) } }, + onClose: { [weak self] in Task { @MainActor in self?.close() } }) + } + return reply.flatMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } + } + + private func handle(_ data: Data) { + guard let message = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let id = (message["id"] as? NSNumber)?.intValue, let waiter = waiters.removeValue(forKey: id) else { return } + let result = message["error"] == nil + ? try? JSONSerialization.data(withJSONObject: message["result"] as? [String: Any] ?? [:]) : nil + waiter.resume(returning: result) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPrompt+Text.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPrompt+Text.swift new file mode 100644 index 000000000000..576cdc583992 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPrompt+Text.swift @@ -0,0 +1,36 @@ +public import Foundation + +/// An acpmux permission request (`request` of `_acpmux/permission_pending`) as the app reads it +/// for the person: the feed bridge and approval banners share these (``AcpmuxPermissionPrompt``). +public extension Dictionary where Key == String, Value == Any { + /// sha256 of the request's canonical JSON (sorted keys): an answer counts only for the same one. + var acpmuxDigest: String { AcpmuxPermissionPrompt.digest(self) } + /// A tool input's `command` as a shell would read it (an argv array shell-quoted). + var acpmuxShownCommand: String? { AcpmuxPermissionPrompt.shownCommand(self["command"]) } + /// A tool input's other fields, scrubbed and cut, and whether anything was cut or dropped. + var acpmuxInputSummary: (fields: [String: String], truncated: Bool)? { AcpmuxPermissionPrompt.inputSummary(self) } +} + +public extension Array where Element == [String: Any] { + /// The option of these offered ones an allow once (`allow`) or a deny maps to; nil when not + /// offered exactly once. + func acpmuxOption(allow: Bool) -> String? { AcpmuxPermissionPrompt.option(allow: allow, offered: self) } +} + +public extension String { + /// Bidi controls and zero-width characters shown as ``. + var acpmuxVisible: String { AcpmuxPermissionPrompt.visible(self) } + /// At most `limit` characters, the last one an ellipsis when cut. + func acpmuxCut(_ limit: Int) -> String { AcpmuxPermissionPrompt.cut(self, limit) } + /// Known secret shapes replaced with `[redacted]` (``FeedSecretScrubber``). + var secretsScrubbed: String { FeedSecretScrubber.scrub(self) } + /// ``secretsScrubbed`` of the first 16 KiB characters (unbounded program output). + var secretsScrubbedPrefix: String { FeedSecretScrubber.scrubPrefix(self) } +} + +public extension Data { + /// One JSON-RPC request line to acpmux. + init(acpmuxRequest id: Int, method: String, params: [String: Any]) { + self = AcpmuxPermissionPrompt.frame(id: id, method: method, params: params) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AgentApprovalBanners.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AgentApprovalBanners.swift new file mode 100644 index 000000000000..6d48135288ea --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AgentApprovalBanners.swift @@ -0,0 +1,97 @@ +import Foundation +public import UserNotifications + +/// One agent chat permission prompt a banner can answer (cx-kz6m0.2): the only pending prompt +/// of a chat on this Mac's acpmux, read when its "needs input" banner posts. +public nonisolated struct AgentApproval: Sendable, Equatable { + public var session: String + public var permission: String + /// sha256 of the prompt's request (``AcpmuxPermissionPrompt/digest(_:)``): an answer counts + /// only while the same request is pending. + public var digest: String + /// What the agent asks to do (its tool call's title and command, scrubbed): the banner's body. + public var summary: String + + public init(session: String, permission: String, digest: String, summary: String) { + (self.session, self.permission, self.digest, self.summary) = (session, permission, digest, summary) + } +} + +/// Reads and answers an agent chat's permission prompt for its banner's Allow once and Deny. +/// The app's own is ``AcpmuxApprovalAnswerer``; tests stand in. +@MainActor +public protocol AgentApprovalAnswering: AnyObject { + /// The chat's one pending tool permission that offers allow once and a deny; nil otherwise + /// (no prompt, a question, several prompts, or no acpmux to ask). + func pendingApproval(session: String) async -> AgentApproval? + /// Answers `approval` (allow once, else deny) while it is still pending unchanged; false + /// when nothing was answered. + func answer(_ approval: AgentApproval, allow: Bool) async -> Bool +} + +/// The approval banners of this launch, by tab (cx-kz6m0.2). A banner waits for its chat's +/// prompt read; a read the tab outlived (read meanwhile, or notified again) posts nothing. A +/// newer banner on a tab retires the older one's buttons, so each tab holds at most one. +@MainActor +public final class AgentApprovalBanners { + public var answerer: (any AgentApprovalAnswering)? + private var banners: [String: (approval: AgentApproval, tab: String)] = [:] + /// Per tab id, the notification whose banner waits for its prompt read. + private var reads: [String: UInt64] = [:] + + public init() {} + + /// Reads `session`'s prompt for notification `id` on `tab`, then posts with it unless the tab + /// was read or notified again meanwhile, or `stillUnread` says the notification was read. + public func read(session: String, notification id: UInt64, tab: String, stillUnread: @escaping @MainActor () -> Bool, + post: @escaping @MainActor (AgentApproval?) -> Void) { + guard let answerer else { return post(nil) } + reads[tab] = id + // task-owner: AgentApprovalBanners.read: one prompt read before one banner posts. + Task { [weak self] in + let approval = await answerer.pendingApproval(session: session) + guard let self, self.reads[tab] == id else { return } + self.reads[tab] = nil + if stillUnread() { post(approval) } + } + } + + /// `tab` posted a banner without a read, or (`retire`) was read: a read in flight posts + /// nothing, and a read tab's approval banners answer nothing. + public func forget(tab: String, retire: Bool) { + reads[tab] = nil + if retire { banners = banners.filter { $0.value.tab != tab } } + } + + /// A banner posted on `tab`: it retires the tab's older approval banners and keeps its own. + public func keep(_ banner: String, approval: AgentApproval?, tab: String) { + banners = banners.filter { $0.value.tab != tab } + if let approval { banners[banner] = (approval, tab) } + } + + /// The prompt and tab of approval banner `banner`, once: nil for any other banner. + public func take(_ banner: String) -> (approval: AgentApproval, tab: String)? { + banners.removeValue(forKey: banner) + } + + public nonisolated static let category = "cmux.notification.approval" + public nonisolated static let allowAction = "cmux.notification.approval.allow-once" + public nonisolated static let denyAction = "cmux.notification.approval.deny" + + /// The banners' category: Allow once (the Mac must be unlocked: an allow runs what the agent + /// asked for) and Deny (destructive). Where macOS hides previews, the command stays hidden. + public nonisolated static func notificationCategory() -> UNNotificationCategory { + let allow = UNNotificationAction(identifier: allowAction, title: String( + localized: "notifications.approval.allowOnce", defaultValue: "Allow once", bundle: .module), options: [.authenticationRequired]) + let deny = UNNotificationAction(identifier: denyAction, title: String( + localized: "notifications.approval.deny", defaultValue: "Deny", bundle: .module), options: [.destructive]) + let hidden = String(localized: "notifications.approval.hiddenBody", defaultValue: "An agent is waiting for your approval", bundle: .module) + return UNNotificationCategory(identifier: category, actions: [allow, deny], intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: hidden, options: []) + } + + /// A banner action's answer: true for Allow once, false for Deny, nil for any other action. + public nonisolated static func decision(_ action: String) -> Bool? { + action == allowAction ? true : action == denyAction ? false : nil + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedSecretScrubber.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/FeedSecretScrubber.swift similarity index 100% rename from Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedSecretScrubber.swift rename to Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/FeedSecretScrubber.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index 60abe5a6adbe..53f41ae59060 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -1,6 +1,402 @@ { "sourceLanguage": "en", "strings": { + "notifications.approval.allowOnce": { + "comment": "Button on an agent chat's approval banner: allows the one tool call the agent asked for.", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح مرة واحدة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dozvoli jednom" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tillad én gang" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einmal erlauben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow once" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir una vez" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser une fois" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Consenti una volta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今回のみ許可" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អនុញ្ញាតម្តង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 번 허용" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tillat én gang" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zezwól raz" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Permitir uma vez" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить один раз" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อนุญาตครั้งเดียว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir kez izin ver" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дозволити один раз" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Cho phép một lần" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许一次" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許一次" + } + } + } + }, + "notifications.approval.deny": { + "comment": "Button on an agent chat's approval banner: denies the tool call the agent asked for.", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رفض" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odbij" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afvis" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablehnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Deny" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Denegar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Refuser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nega" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "拒否" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បដិសេធ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "거부" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avslå" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odmów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Negar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отклонить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปฏิเสธ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Reddet" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відхилити" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Từ chối" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拒绝" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拒絕" + } + } + } + }, + "notifications.approval.hiddenBody": { + "comment": "Body of an agent chat's approval banner where macOS hides notification previews (for example on the lock screen).", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وكيل ينتظر موافقتك" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Agent čeka vaše odobrenje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "En agent venter på din godkendelse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Agent wartet auf deine Freigabe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "An agent is waiting for your approval" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un agente espera tu aprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un agent attend votre approbation" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un agente attende la tua approvazione" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントが承認を待っています" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ភ្នាក់ងារកំពុងរង់ចាំការយល់ព្រមរបស់អ្នក" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트가 승인을 기다리고 있습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "En agent venter på godkjenningen din" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Agent czeka na Twoją zgodę" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Um agente está aguardando sua aprovação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Агент ждёт вашего одобрения" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอเจนต์กำลังรอการอนุมัติจากคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir ajan onayınızı bekliyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агент чекає на ваше схвалення" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Một tác tử đang chờ bạn phê duyệt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理正在等待你的批准" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理程式正在等待你的核准" + } + } + } + }, "agentPane.addFiles.prompt": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift index 92dc2937546e..9f3ee13f85d5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift @@ -2,7 +2,6 @@ import CmuxNextAgentActivity import CmuxNextAgentPane import CmuxNextFeed import CmuxNextWakeups -import CryptoKit import Foundation import os @@ -115,32 +114,24 @@ final class AcpmuxPermissionFeedBridge { guard FileManager.default.fileExists(atPath: socket) else { return scheduleReconnect() } let line = AcpmuxCheckedLine(environment: environment) connection = line - line.start(send: Self.frame(id: 1, method: "initialize", params: [ + line.start(send: Data(acpmuxRequest: 1, method: "initialize", params: [ "protocolVersion": 1, "clientInfo": ["name": "cmux-next-feed-bridge", "version": "1"], "clientCapabilities": [String: Any](), ]), onLine: { [weak self] data in Task { @MainActor in self?.handle(data) } }, onClose: { [weak self] in Task { @MainActor in self?.lost(line) } }) } - /// One JSON-RPC request line. - static func frame(id: Int, method: String, params: [String: Any]) -> Data { - let request: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method, "params": params] - var data = (try? JSONSerialization.data(withJSONObject: request)) ?? Data() - data.append(0x0A) - return data - } - /// The `initialize` reply: the person proof for its challenge as the SECOND request (acpmux /// `hub/person.rs`, transport unix), then `_acpmux/watch` over every session. private func initialized(_ result: [String: Any]?, on line: AcpmuxCheckedLine) { let challenge = ((result?["_meta"] as? [String: Any])?["acpmux"] as? [String: Any])?["personChallenge"] as? [String: Any] if let nonce = challenge?["nonce"] as? String, let id = challenge?["connection"] as? String, let proof = line.personProof(nonce: nonce, connection: id) { - line.send(Self.frame(id: 2, method: "_acpmux/person_prove", params: ["proof": proof])) + line.send(Data(acpmuxRequest: 2, method: "_acpmux/person_prove", params: ["proof": proof])) } else { logger.error("feed bridge: no person challenge or no key for this daemon; phone answers stay refused") } - line.send(Self.frame(id: 3, method: "_acpmux/watch", params: ["enabled": true])) + line.send(Data(acpmuxRequest: 3, method: "_acpmux/watch", params: ["enabled": true])) } private func lost(_ line: AcpmuxCheckedLine) { @@ -231,43 +222,37 @@ final class AcpmuxPermissionFeedBridge { // MARK: Feed - /// sha256 of the request's canonical JSON (sorted keys). - nonisolated static func digest(_ request: [String: Any]) -> String { - let data = (try? JSONSerialization.data(withJSONObject: request, options: [.sortedKeys, .withoutEscapingSlashes])) ?? Data() - return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - } - /// The feed.post body for one prompt: what the person must see to decide. /// `host`: this Mac's install id (`context.host`), the install the phone's proof names. nonisolated static func postBody(session: String, permission: String, request: [String: Any], host: String) -> [String: Any] { let call = request["toolCall"] as? [String: Any] ?? [:] let rawTitle = call["title"] as? String ?? "" - let title = visible(FeedSecretScrubber.scrub(rawTitle)) - let kind = visible(call["kind"] as? String ?? "") + let title = rawTitle.secretsScrubbed.acpmuxVisible + let kind = (call["kind"] as? String ?? "").acpmuxVisible let input = call["rawInput"] as? [String: Any] ?? [:] - let rawCommand = shownCommand(input["command"]) - let command = rawCommand.map { visible(FeedSecretScrubber.scrub($0)) } - let summary = inputSummary(input) + let rawCommand = input.acpmuxShownCommand + let command = rawCommand.map { $0.secretsScrubbed.acpmuxVisible } + let summary = input.acpmuxInputSummary // Anything cut, dropped or redacted: the phone shows it shortened and never allows it // (cx-aocz): a redaction must never hide what the person signs. - let redacted = (rawCommand.map { FeedSecretScrubber.scrub($0) != $0 } ?? false) - || FeedSecretScrubber.scrub(rawTitle) != rawTitle + let redacted = (rawCommand.map { $0.secretsScrubbed != $0 } ?? false) + || rawTitle.secretsScrubbed != rawTitle let truncated = (command?.count ?? 0) > 8000 || title.count > 500 || kind.count > 200 || redacted || (summary?.truncated ?? false) var action: [String: Any] = [ "type": kind == "execute" ? "command" : kind == "edit" || kind == "delete" || kind == "move" ? "edit" : "tool", - "summary": cut(title.isEmpty ? String(localized: "feed.bridge.summary", defaultValue: "An agent asks to run a tool", bundle: .module) : title, 500), + "summary": (title.isEmpty ? String(localized: "feed.bridge.summary", defaultValue: "An agent asks to run a tool", bundle: .module) : title).acpmuxCut(500), ] - if !kind.isEmpty { action["tool"] = cut(kind, 200) } - if let command, !command.isEmpty { action["command"] = cut(command, 8000) } + if !kind.isEmpty { action["tool"] = kind.acpmuxCut(200) } + if let command, !command.isEmpty { action["command"] = command.acpmuxCut(8000) } if let summary { action["input"] = summary.fields } if truncated { action["truncated"] = true } let key = "acpmux:\(permission)" - let heading = String(localized: "feed.bridge.title", defaultValue: "Allow \(cut(title.isEmpty ? kind : title, 120))?", bundle: .module) + let heading = String(localized: "feed.bridge.title", defaultValue: "Allow \((title.isEmpty ? kind : title).acpmuxCut(120))?", bundle: .module) var params: [String: Any] = [ "type": "request", "kind": "approve", - "title": cut(heading, 200), + "title": heading.acpmuxCut(200), "prompt": ["action": action, "scopes": ["once"]], // The phone reads the Mac it answers from here (FeedApproveRequest). "context": ["host": String(host.prefix(128)), "acp_session": String(session.prefix(128))], @@ -275,72 +260,10 @@ final class AcpmuxPermissionFeedBridge { "thread": String("acpmux:\(session)".prefix(200)), "poster": ["label": String(localized: "feed.bridge.poster", defaultValue: "Agent", bundle: .module)], ] - if let command, !command.isEmpty { params["body"] = cut(command, 4096) } + if let command, !command.isEmpty { params["body"] = command.acpmuxCut(4096) } return ["op": "feed.post", "params": params, "idempotency_key": String(key.prefix(200)), "origin": "script"] } - /// The command a tool input runs: a string, or an argv array joined as a shell would read it - /// (`["bash", "-lc", "rm -rf ~"]` shows as `bash -lc 'rm -rf ~'`). An array with other values - /// shows as compact JSON. - nonisolated static func shownCommand(_ value: Any?) -> String? { - if let text = value as? String { return text } - guard let parts = value as? [Any] else { return value.map(compactJSON) } - guard let words = parts as? [String] else { return compactJSON(parts) } - return words.map(shellQuoted).joined(separator: " ") - } - - nonisolated static func shellQuoted(_ word: String) -> String { - let plain = !word.isEmpty && word.unicodeScalars.allSatisfy { - CharacterSet.alphanumerics.contains($0) && $0.isASCII || "-_./=:,+@%".unicodeScalars.contains($0) - } - return plain ? word : "'" + word.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - - nonisolated static func compactJSON(_ value: Any) -> String { - let data = (try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .withoutEscapingSlashes, .fragmentsAllowed])) ?? Data() - return String(decoding: data, as: UTF8.self) - } - - /// Bidi controls and zero-width characters shown as ``, so a command cannot read - /// one way and run another. - nonisolated static func visible(_ text: String) -> String { - var out = "" - for scalar in text.unicodeScalars { - switch scalar.value { - case 0x061C, 0x180E, 0x200B...0x200F, 0x202A...0x202E, 0x2060...0x2064, 0x2066...0x2069, 0xFEFF: - out += String(format: "", scalar.value) - default: - out.unicodeScalars.append(scalar) - } - } - return out - } - - /// A short, scrubbed summary of a tool's input besides its command: field names with their - /// values (paths, patterns; other values as compact JSON), `cwd` first whenever present. - /// `env` is never shown, and its presence marks the summary truncated. `truncated`: a field - /// beyond the first 8, a long name, a long value, or `env` (the phone then never allows). - nonisolated static func inputSummary(_ input: [String: Any]) -> (fields: [String: String], truncated: Bool)? { - var out: [String: String] = [:] - let env = input.first { $0.key.lowercased() == "env" } - var truncated = env.map { !(($0.value as? [String: Any])?.isEmpty ?? false) } ?? false - let shown = input.filter { $0.key.lowercased() != "env" && $0.key != "command" } - .sorted { ($0.key == "cwd" ? 0 : 1, $0.key) < ($1.key == "cwd" ? 0 : 1, $1.key) } - if shown.count > 8 { truncated = true } - for (key, value) in shown.prefix(8) { - let raw = value as? String ?? compactJSON(value) - let scrubbed = FeedSecretScrubber.scrub(raw) - // A redaction must never hide what the person signs. - if scrubbed != raw { truncated = true } - let text = visible(scrubbed) - if key.count > 40 || text.count > 200 { truncated = true } - out[cut(visible(key), 40)] = cut(text, 200) - } - // Two shown names that read the same after the cut collide: one value is not shown. - if out.count < min(shown.count, 8) { truncated = true } - return out.isEmpty && !truncated ? nil : (out, truncated) - } - private func post(session: String, permission: String, request: [String: Any]) async { guard isSignedIn(), postedPermissions.insert(permission).inserted else { return } do { @@ -356,7 +279,7 @@ final class AcpmuxPermissionFeedBridge { return logger.error("feed bridge: the owner returned no item for \(permission, privacy: .public)") } let action = ((body["params"] as? [String: Any])?["prompt"] as? [String: Any])?["action"] as? [String: Any] ?? [:] - posted[id] = Posted(session: session, permission: permission, digest: Self.digest(request), + posted[id] = Posted(session: session, permission: permission, digest: request.acpmuxDigest, shown: FeedApproveProofCheck.shownSHA256(action: action), truncated: action["truncated"] as? Bool ?? false) logger.info("feed bridge: posted \(id, privacy: .public) for \(permission, privacy: .public)") @@ -406,12 +329,7 @@ final class AcpmuxPermissionFeedBridge { /// The acpmux option a decision maps to, from what the prompt offers now. nonisolated static func option(for decision: FeedAnswerValue.Decision, offered: [[String: Any]]) -> Result { if decision.outcome == .allow, let scope = decision.scope, scope != .once { return .failure(.scope) } - let kinds = decision.outcome == .allow ? ["allow_once"] : ["reject_once", "reject_always"] - for kind in kinds { - let matches = offered.filter { $0["kind"] as? String == kind } - if matches.count == 1, let id = matches[0]["optionId"] as? String { return .success(id) } - } - return .failure(.notOffered) + return offered.acpmuxOption(allow: decision.outcome == .allow).map { .success($0) } ?? .failure(.notOffered) } /// Whether `by` may answer for the person here: an install (not a session) @@ -434,7 +352,7 @@ final class AcpmuxPermissionFeedBridge { let request = now["request"] as? [String: Any] else { return logger.info("feed bridge: \(item, privacy: .public) answered after the prompt ended; nothing answered") } - guard Self.digest(request) == entry.digest else { + guard request.acpmuxDigest == entry.digest else { return logger.error("feed bridge: \(item, privacy: .public): the prompt changed since it was posted; nothing answered") } let option: String @@ -471,8 +389,4 @@ final class AcpmuxPermissionFeedBridge { logger.info("feed bridge: answered \(entry.permission, privacy: .public) with \(option, privacy: .public) for \(by, privacy: .public) (\(device, privacy: .public))") } } - - nonisolated static func cut(_ text: String, _ limit: Int) -> String { - text.count > limit ? String(text.prefix(limit - 1)) + "…" : text - } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift index 32ea46b0402c..af3c14d1b673 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift @@ -1,3 +1,4 @@ +import CmuxNextAgentPane import CmuxNextDaemon import CmuxNextSettings import Foundation @@ -47,7 +48,7 @@ nonisolated struct FeedHandoffPolicy: Sendable { default: text = mirror.agents ? (item.title, item.body) : nil } - return text.map { (FeedSecretScrubber.scrubPrefix($0.0), FeedSecretScrubber.scrubPrefix($0.1)) } + return text.map { ($0.0.secretsScrubbedPrefix, $0.1.secretsScrubbedPrefix) } } private func arrivedInQuietHours(_ item: FeedLocalItem) -> Bool { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift index 7d6bdef0387f..7272b765a046 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift @@ -1,3 +1,4 @@ +import CmuxNextAgentPane import CmuxNextDaemon import CryptoKit import Foundation @@ -32,14 +33,14 @@ extension FeedHandoffDriver { for (key, value) in [("workspace", item.context.workspace), ("tab", item.context.tab), ("terminal", item.context.terminal)] { if let value, !value.isEmpty { context[key] = cut(value, 128) } } - let title = cut(FeedSecretScrubber.scrubPrefix(content.title), 200) + let title = cut(content.title.secretsScrubbedPrefix, 200) let cloud: [String: Any] = [ "id": cloudID(install: install, local: item.id), "home": "local:\(install)", "type": "notice", "kind": "notice", "title": title.isEmpty ? "cmux" : title, - "body": cut(FeedSecretScrubber.scrubPrefix(content.body), 4096), + "body": cut(content.body.secretsScrubbedPrefix, 4096), "priority": high ? "high" : "normal", "dedupe_key": cut(item.dedupeKey, 200), "thread": orNull(item.context.tab.map { cut("tab:\($0)", 200) }), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DebugNotifications.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DebugNotifications.swift index bc85aafd4b41..9e81c48b8a32 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DebugNotifications.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DebugNotifications.swift @@ -8,6 +8,8 @@ import CmuxNextSettings /// banner click path (`DesktopNotifier.open`), for windows no one can click; /// `{"action": "reply", "surface": , "id": , "text": }` runs a reply /// banner's Send (`DesktopNotifier.reply`). +/// `{"action": "allow" | "deny", "surface": , "id": }` runs an approval +/// banner's Allow once or Deny (`DesktopNotifier.approve`). /// `{"action": "dismiss_highlight", "workspace": }` hides that /// workspace's attention rings until a newer notification (cx-epgo). enum DebugNotifications { @@ -22,6 +24,10 @@ enum DebugNotifications { let surface = params["surface"]?.doubleValue.map { UInt64($0) } center.desktop.reply(id: params["id"]?.stringValue ?? "", surface: surface, text: text) } + if let action = params["action"]?.stringValue, action == "allow" || action == "deny" { + let surface = params["surface"]?.doubleValue.map { UInt64($0) } + center.desktop.approve(id: params["id"]?.stringValue ?? "", surface: surface, allow: action == "allow") + } let store = services.daemon.store // `{"action": "dismiss_highlight", "workspace": }`: the Dismiss Highlight path (cx-epgo). if params["action"]?.stringValue == "dismiss_highlight", let id = params["workspace"]?.stringValue, @@ -79,7 +85,7 @@ enum DebugNotifications { "id": .string(banner.id), "title": .string(banner.title), "subtitle": banner.subtitle.map(JSONValue.string) ?? .null, "body": .string(banner.body), "surface": banner.surface.map { .number(Double($0)) } ?? .null, - "reply": .bool(banner.reply), + "reply": .bool(banner.reply), "approval": .bool(banner.approval), "attachment": banner.attachment.map { data in ["bytes": .number(Double(data.count)), "png_base64": .string(data.base64EncodedString())] } ?? .null, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift index 497684dc1648..9efda2735463 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxNextAgentPane import CmuxNextDesign import os import UserNotifications @@ -27,12 +28,16 @@ final class DesktopNotifier: NSObject { var attachment: Data? /// The banner takes a typed reply (`notify --reply`). var reply = false + /// The banner answers an agent chat's permission prompt (Allow once, Deny). + var approval = false } /// Banner clicked: the notification id and its tab's surface handle. var onOpen: ((_ id: String, _ surface: UInt64?) -> Void)? /// A reply banner's text sent: the notification id, its tab's surface handle and the text. var onReply: ((_ id: String, _ surface: UInt64?, _ text: String) -> Void)? + /// An approval banner's Allow once (true) or Deny (false): the notification id, its tab's surface handle. + var onApprove: ((_ id: String, _ surface: UInt64?, _ allow: Bool) -> Void)? /// The category of banners with a Reply field, and its action. nonisolated static let replyCategory = "cmux.notification.reply" nonisolated static let replyAction = "cmux.notification.reply.send" @@ -54,9 +59,10 @@ final class DesktopNotifier: NSObject { /// (OSC 7501 alerts), written to a file only for a banner the center /// will take (it moves the file); a failed one is deleted. func post(id: String, title: String, subtitle: String? = nil, body: String, surface: UInt64?, workspace: String?, - defaultSound: Bool, attachment: Data? = nil, reply: Bool = false) { + defaultSound: Bool, attachment: Data? = nil, reply: Bool = false, approval: Bool = false) { posted.append(Posted(id: id, title: title, subtitle: subtitle, body: body, surface: surface, - sound: defaultSound ? "default" : nil, attachment: attachment, reply: reply)) + sound: defaultSound ? "default" : nil, attachment: attachment, reply: reply, + approval: approval)) if posted.count > Self.postedLimit { posted.removeFirst(posted.count - Self.postedLimit) } guard let center = resolvedCenter() else { return } let content = UNMutableNotificationContent() @@ -75,6 +81,7 @@ final class DesktopNotifier: NSObject { content.interruptionLevel = .active if let workspace { content.threadIdentifier = workspace } if reply { content.categoryIdentifier = Self.replyCategory } + if approval { content.categoryIdentifier = AgentApprovalBanners.category } var info: [String: Any] = ["notification": id] if let surface { info["surface"] = NSNumber(value: surface) } content.userInfo = info @@ -116,6 +123,11 @@ final class DesktopNotifier: NSObject { onReply?(id, surface, text) } + /// An approval banner's Allow once or Deny, also used by `debug.notifications` (allow, deny). + func approve(id: String, surface: UInt64?, allow: Bool) { + onApprove?(id, surface, allow) + } + /// CEF embeds the same executable in helper app bundles whose identifiers /// contain a `.helper.*` component. Only the main cmux app may own the /// macOS notification center and request authorization. @@ -135,7 +147,7 @@ final class DesktopNotifier: NSObject { } let center = UNUserNotificationCenter.current() center.delegate = self - center.setNotificationCategories([Self.replyBannerCategory()]) + center.setNotificationCategories([Self.replyBannerCategory(), AgentApprovalBanners.notificationCategory()]) self.center = center if !requestedAuthorization { requestedAuthorization = true @@ -174,9 +186,11 @@ extension DesktopNotifier: UNUserNotificationCenterDelegate { let surface = (info["surface"] as? NSNumber)?.uint64Value let isDefault = response.actionIdentifier == UNNotificationDefaultActionIdentifier let replied = response.actionIdentifier == Self.replyAction ? (response as? UNTextInputNotificationResponse)?.userText : nil + let approved = AgentApprovalBanners.decision(response.actionIdentifier) Task { @MainActor [weak self] in if isDefault { self?.open(id: id, surface: surface) } if let replied { self?.reply(id: id, surface: surface, text: replied) } + if let approved { self?.approve(id: id, surface: surface, allow: approved) } } completionHandler() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift new file mode 100644 index 000000000000..be898dbabbd7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift @@ -0,0 +1,50 @@ +import CmuxNextAgentPane +import CmuxNextDaemon +import CmuxNextSettings + +/// Wiring of agent chat approval banners (cx-kz6m0.2, ``AgentApprovalBanners``): an agent's ask +/// on a chat tab whose session runs on this Mac's acpmux gets Allow once and Deny. +extension NotificationCenterService { + func followApprovals(_ services: AppServices) { + if approvals.answerer == nil, let environment = QuitAgents.environment(services) { + approvals.answerer = AcpmuxApprovalAnswerer(environment: environment) + } + localAgentHost = { [weak services] in services?.agentTabs.localHost } + desktop.onApprove = { [weak self] id, surface, allow in + self?.answerApproval(banner: id, surface: surface.map(SurfaceID.init(rawValue:)), allow: allow) + } + } + + /// Runs `post` with the chat's pending permission when `notification` is an agent's ask (a + /// warning) on a chat of this Mac; at once with nil otherwise (a terminal agent's hook + /// approval has no prompt this app can answer). + func withApproval(of notification: DaemonNotification, tab: TabModel?, post: @escaping @MainActor (AgentApproval?) -> Void) { + guard let tab else { return post(nil) } + guard approvals.answerer != nil, notification.source == NotificationSource.agent.rawValue, notification.level == .warning, + let ref = tab.agentSession, let session = ref.session, let local = localAgentHost(), ref.host == local else { + approvals.forget(tab: tab.id, retire: false) + return post(nil) + } + let id = notification.notification.rawValue + approvals.read(session: session, notification: id, tab: tab.id, stillUnread: { + guard let marker = tab.notification else { return true } + return marker.notification.rawValue < id || (marker.notification.rawValue == id && marker.unread) + }, post: post) + } + + /// Allow once or Deny on an approval banner of this launch. An answered prompt reads the tab; + /// one that ended or changed opens it, where the chat shows what it waits on now. + func answerApproval(banner: String, surface: SurfaceID?, allow: Bool) { + guard let entry = approvals.take(banner), let answerer = approvals.answerer else { + return note("approval refused: \(banner) is not an approval banner of this launch") + } + // task-owner: NotificationCenterService.answerApproval: one answer per banner button. + Task { @MainActor [weak self] in + let answered = await answerer.answer(entry.approval, allow: allow) + self?.note("approval \(allow ? "allow" : "deny") \(entry.tab) \(answered ? "answered" : "not answered")") + guard let self, let services = self.services else { return } + if !answered { return self.open(surface: surface) } + if let tab = Self.tab(id: entry.tab, in: services.daemon.store) { self.acknowledge(tab) } + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift index 42a1821fc255..58399d47eed7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxNextAgentPane import CmuxNextBridge import CmuxNextCompat import CmuxNextDaemon @@ -47,6 +48,9 @@ final class NotificationCenterService { /// Agent permission prompts to the person's feed, while /// `feed.agentPermissionPrompts` is on (cx-aocz). @ObservationIgnored var permissionBridge: AcpmuxPermissionFeedBridge? + /// Agent chat approval banners (cx-kz6m0.2) and this Mac's `install:` they need. + @ObservationIgnored let approvals = AgentApprovalBanners() + @ObservationIgnored var localAgentHost: @MainActor () -> String? = { nil } @ObservationIgnored private var tasks: [Task] = [] /// Recent arrivals and what was decided (for `debug.notifications`). @ObservationIgnored private(set) var log: [String] = [] @@ -90,6 +94,7 @@ final class NotificationCenterService { desktop.onReply = { [weak self] id, surface, text in self?.reply(banner: id, surface: surface.map(SurfaceID.init(rawValue:)), text: text) } + followApprovals(services) let store = services.daemon.store lastSeen = store.notifications.map(\.notification.rawValue).max() ?? 0 tasks.append(Task { [weak self] in @@ -219,6 +224,7 @@ final class NotificationCenterService { func acknowledge(_ tab: TabModel) { note("ack \(tab.id)") timeouts.removeValue(forKey: tab.id)?.cancel() + approvals.forget(tab: tab.id, retire: true) finishedBursts.read(tab: tab.id) desktop.withdraw(banners.removeValue(forKey: tab.id) ?? []) let surface = tab.surface @@ -333,12 +339,19 @@ final class NotificationCenterService { // An OSC 7501 alert reads in the app's language; the daemon's English is the fallback. let title = title ?? program?.title(localizations: localizations) ?? (notification.title.isEmpty ? (tab?.displayTitle ?? "cmux") : notification.title) - desktop.post(id: id, title: title, subtitle: subtitle, body: notification.body, surface: notification.surface?.rawValue, - workspace: workspace, defaultSound: sound == "default", - attachment: program.flatMap { StatusNotificationImage.data($0.reason) }, - reply: notification.reply && tab?.kind == .pty) - if let sound, sound != "default" { NotificationSounds.play(sound) } - if let tab { banners[tab.id, default: []].append(id) } + // An agent chat's permission prompt: Allow once and Deny, and what it asks as the body. + withApproval(of: notification, tab: tab) { [weak self] approval in + guard let self else { return } + self.desktop.post(id: id, title: title, subtitle: subtitle, body: approval?.summary ?? notification.body, + surface: notification.surface?.rawValue, workspace: workspace, defaultSound: sound == "default", + attachment: program.flatMap { StatusNotificationImage.data($0.reason) }, + reply: notification.reply && tab?.kind == .pty, approval: approval != nil) + if let sound, sound != "default" { NotificationSounds.play(sound) } + if let tab { + self.banners[tab.id, default: []].append(id) + self.approvals.keep(id, approval: approval, tab: tab.id) + } + } } /// A terminal program chose its banner's title and text (OSC 9/777/99, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift new file mode 100644 index 000000000000..f3023db24e0f --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift @@ -0,0 +1,204 @@ +import CmuxNextAgentPane +@testable import CmuxNextApp +@testable import CmuxNextDaemon +import CmuxNextSettings +import Foundation +import Testing + +/// An agent chat that asks for a tool permission gets a banner with Allow once and Deny +/// (cx-kz6m0.2, parity with classic's feed permission banners). The banner says what the agent +/// asks to do, and its buttons answer the chat's prompt through the app's acpmux answerer. +@MainActor +struct ApprovalBannerTests { + /// Stands in for the app's acpmux: one pending prompt per session, and the answers it got. + final class Answerer: AgentApprovalAnswering { + var pending: [String: AgentApproval] = [:] + var answered: [(approval: AgentApproval, allow: Bool)] = [] + var lookups: [String] = [] + + func pendingApproval(session: String) async -> AgentApproval? { + lookups.append(session) + return pending[session] + } + + func answer(_ approval: AgentApproval, allow: Bool) async -> Bool { + answered.append((approval, allow)) + return true + } + } + + static let thisMac = "install:this-mac" + static let bash = AgentApproval(session: "sess-1", permission: "perm-9", digest: "d1", + summary: "Run tests\nswift test --filter Login") + static let needsInput = #"{"notification":7,"title":"Fix login needs input","body":"I need to run the tests.","level":"warning","surface":1,"source":"agent"}"# + + /// Delivers daemon notification `wire` on one tab (an agent chat on `host` when `session` + /// is set, else a terminal) and returns the service once its banner posted. + func deliver(_ wire: String, session: String? = "sess-1", host: String = thisMac, + answerer: Answerer) async throws -> NotificationCenterService { + let store = DaemonStore() + var tab = TabSnapshot(surface: 1, kind: session == nil ? .pty : .conversation) + if let session { tab.conversation = ConversationTabRef(agentSession: AgentSessionRef(host: host, session: session)) } + let pane = PaneSnapshot(id: 1, tabs: [tab]) + let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [pane]) + store.apply(snapshot: DaemonTree(workspaces: [WorkspaceSnapshot(id: 1, key: nil, name: "api", screens: [screen])])) + let workspace = try #require(store.workspaces.first) + let paneModel = try #require(workspace.screens.first?.panes.first) + let located = LocatedTab(tab: try #require(paneModel.tabs.first), pane: paneModel, workspace: workspace) + let notification = try JSONDecoder().decode(DaemonNotification.self, from: Data(wire.utf8)) + let service = NotificationCenterService() + service.approvals.answerer = answerer + service.localAgentHost = { Self.thisMac } + var decision = NotificationPolicy.Decision() + decision.desktop = true + let source = NotificationSource(rawValue: notification.source ?? "") ?? .agent + service.deliver(notification, source: source, located: located, decision: decision, program: nil) + // The banner may wait for the chat's prompt to be read. + for _ in 0..<200 where service.desktop.posted.isEmpty { await Task.yield() } + return service + } + + @Test func aChatPermissionBannerOffersAllowOnceAndDenyAndSaysWhatItAllows() async throws { + let answerer = Answerer() + answerer.pending["sess-1"] = Self.bash + let service = try await deliver(Self.needsInput, answerer: answerer) + let posted = try #require(service.desktop.posted.last) + #expect(posted.approval, "the banner carries the Allow once / Deny category") + #expect(posted.title == "Fix login needs input") + #expect(posted.body == "Run tests\nswift test --filter Login") + } + + @Test func allowOnceAndDenyAnswerTheChatsPrompt() async throws { + let answerer = Answerer() + answerer.pending["sess-1"] = Self.bash + let service = try await deliver(Self.needsInput, answerer: answerer) + let posted = try #require(service.desktop.posted.last) + service.answerApproval(banner: posted.id, surface: SurfaceID(rawValue: 1), allow: true) + for _ in 0..<200 where answerer.answered.isEmpty { await Task.yield() } + #expect(answerer.answered.map(\.approval) == [Self.bash]) + #expect(answerer.answered.map(\.allow) == [true]) + + let denied = Answerer() + denied.pending["sess-1"] = Self.bash + let other = try await deliver(Self.needsInput, answerer: denied) + other.answerApproval(banner: try #require(other.desktop.posted.last).id, surface: SurfaceID(rawValue: 1), allow: false) + for _ in 0..<200 where denied.answered.isEmpty { await Task.yield() } + #expect(denied.answered.map(\.allow) == [false]) + } + + @Test func noPromptAnotherMacOrATerminalKeepsAPlainBanner() async throws { + // A question (or nothing pending): the answerer finds no permission. + let none = Answerer() + let plain = try #require(try await deliver(Self.needsInput, answerer: none).desktop.posted.last) + #expect(!plain.approval) + #expect(plain.body == "I need to run the tests.") + + // A chat on another Mac's acpmux: this Mac never asks or answers for it. + let elsewhere = Answerer() + elsewhere.pending["sess-1"] = Self.bash + let remote = try #require(try await deliver(Self.needsInput, host: "install:other", answerer: elsewhere).desktop.posted.last) + #expect(!remote.approval) + #expect(elsewhere.lookups.isEmpty) + + // A terminal agent's hook approval has no prompt this app can answer. + let terminal = Answerer() + let hook = #"{"notification":8,"title":"Claude needs approval","body":"Bash","level":"warning","surface":1,"source":"agent"}"# + let posted = try #require(try await deliver(hook, session: nil, answerer: terminal).desktop.posted.last) + #expect(!posted.approval) + #expect(terminal.lookups.isEmpty) + } + + @Test func aBannerThisLaunchDidNotPostAnswersNothing() async throws { + let answerer = Answerer() + answerer.pending["sess-1"] = Self.bash + let service = try await deliver(Self.needsInput, answerer: answerer) + service.answerApproval(banner: "cmux-notification-1", surface: SurfaceID(rawValue: 1), allow: true) + for _ in 0..<50 { await Task.yield() } + #expect(answerer.answered.isEmpty) + } + + /// Holds each prompt read until the test releases it, as a slow acpmux would. + final class SlowAnswerer: AgentApprovalAnswering { + var approval: AgentApproval? + var waiting: [CheckedContinuation] = [] + + func pendingApproval(session: String) async -> AgentApproval? { + await withCheckedContinuation { waiting.append($0) } + return approval + } + + func answer(_ approval: AgentApproval, allow: Bool) async -> Bool { true } + + func release() { + let held = waiting + waiting = [] + held.forEach { $0.resume() } + } + } + + /// One agent chat tab on this Mac, and a service that answers through `answerer`. + func chat(answerer: any AgentApprovalAnswering) throws -> (NotificationCenterService, LocatedTab) { + let store = DaemonStore() + var tab = TabSnapshot(surface: 1, kind: .conversation) + tab.conversation = ConversationTabRef(agentSession: AgentSessionRef(host: Self.thisMac, session: "sess-1")) + let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [PaneSnapshot(id: 1, tabs: [tab])]) + store.apply(snapshot: DaemonTree(workspaces: [WorkspaceSnapshot(id: 1, key: nil, name: "api", screens: [screen])])) + let workspace = try #require(store.workspaces.first) + let pane = try #require(workspace.screens.first?.panes.first) + let service = NotificationCenterService() + service.approvals.answerer = answerer + service.localAgentHost = { Self.thisMac } + return (service, LocatedTab(tab: try #require(pane.tabs.first), pane: pane, workspace: workspace)) + } + + /// "Fix login needs input" as daemon notification `id`, delivered on `located`. + func ask(_ id: Int, _ service: NotificationCenterService, _ located: LocatedTab) throws { + let wire = #"{"notification":\#(id),"title":"Fix login needs input","body":"","level":"warning","surface":1,"source":"agent"}"# + let notification = try JSONDecoder().decode(DaemonNotification.self, from: Data(wire.utf8)) + var decision = NotificationPolicy.Decision() + decision.desktop = true + service.deliver(notification, source: .agent, located: located, decision: decision, program: nil) + } + + @Test func aChatReadWhileItsPromptIsReadPostsNoBanner() async throws { + let slow = SlowAnswerer() + slow.approval = Self.bash + let (service, located) = try chat(answerer: slow) + try ask(7, service, located) + for _ in 0..<200 where slow.waiting.isEmpty { await Task.yield() } + // The user looked at the chat (or answered it there) before acpmux replied. + service.acknowledge(located.tab) + slow.release() + for _ in 0..<200 { await Task.yield() } + #expect(service.desktop.posted.isEmpty, "no banner for a notification already read") + } + + @Test func aNewerAskWhileTheFirstIsReadPostsOnlyTheNewerBanner() async throws { + let slow = SlowAnswerer() + slow.approval = Self.bash + let (service, located) = try chat(answerer: slow) + try ask(7, service, located) + for _ in 0..<200 where slow.waiting.isEmpty { await Task.yield() } + try ask(8, service, located) + for _ in 0..<200 where slow.waiting.count < 2 { await Task.yield() } + slow.release() + for _ in 0..<200 { await Task.yield() } + #expect(service.desktop.posted.map(\.id) == ["cmux-notification-8"]) + } + + @Test func aNewerApprovalBannerRetiresTheOlderOnesButtons() async throws { + let answerer = Answerer() + answerer.pending["sess-1"] = Self.bash + let (service, located) = try chat(answerer: answerer) + try ask(7, service, located) + for _ in 0..<200 where service.desktop.posted.isEmpty { await Task.yield() } + try ask(8, service, located) + for _ in 0..<200 where service.desktop.posted.count < 2 { await Task.yield() } + service.answerApproval(banner: "cmux-notification-7", surface: SurfaceID(rawValue: 1), allow: true) + for _ in 0..<50 { await Task.yield() } + #expect(answerer.answered.isEmpty, "the older banner's prompt is the newer one's now") + service.answerApproval(banner: "cmux-notification-8", surface: SurfaceID(rawValue: 1), allow: true) + for _ in 0..<200 where answerer.answered.isEmpty { await Task.yield() } + #expect(answerer.answered.map(\.allow) == [true]) + } +}