From 8358452d0c1e4b7df0c35d4310921f1197aea784 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:44:53 -0700 Subject: [PATCH 1/7] test(cx-kz6m0.2): agent chat approval banners offer Allow once and Deny Red: the banner posts without the approval category and its buttons answer nothing yet. Co-Authored-By: Claude Opus 5.5 --- .../Notifications/AgentApprovalBanners.swift | 25 ++++ .../Notifications/DesktopNotifier.swift | 2 + .../NotificationCenterService+Approvals.swift | 7 ++ .../NotificationCenterService.swift | 6 + .../ApprovalBannerTests.swift | 117 ++++++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift new file mode 100644 index 000000000000..d499e319dc86 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift @@ -0,0 +1,25 @@ +import Foundation + +/// 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. +struct AgentApproval: Sendable, Equatable { + var session: String + var permission: String + /// sha256 of the prompt's request (``AcpmuxPermissionFeedBridge/digest(_:)``): an answer + /// counts only while the same request is pending. + var digest: String + /// What the agent asks to do (its tool call's title and command, scrubbed): the banner's body. + var summary: String +} + +/// 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 +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 +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift index 497684dc1648..9a46a93f3182 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift @@ -27,6 +27,8 @@ 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. 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..80ebbd0755a8 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift @@ -0,0 +1,7 @@ +import CmuxNextDaemon +import Foundation + +extension NotificationCenterService { + /// Allow once or Deny on an approval banner (cx-kz6m0.2). + func answerApproval(banner: String, surface: SurfaceID?, allow: Bool) {} +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift index 352685e36925..b8b4b566bb9c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift @@ -46,6 +46,12 @@ final class NotificationCenterService { /// Agent permission prompts to the person's feed, while /// `feed.agentPermissionPrompts` is on (cx-aocz). @ObservationIgnored var permissionBridge: AcpmuxPermissionFeedBridge? + /// Reads and answers agent chat prompts for approval banners (cx-kz6m0.2); tests stand in. + @ObservationIgnored var approvalAnswerer: (any AgentApprovalAnswering)? + /// This Mac's `install:`: only a chat whose session runs here gets Allow once and Deny. + @ObservationIgnored var localAgentHost: @MainActor () -> String? = { nil } + /// Approval banners posted this launch, by banner id: what Allow once or Deny answers. + @ObservationIgnored var approvalBanners: [String: (approval: AgentApproval, tab: String)] = [:] @ObservationIgnored private var tasks: [Task] = [] /// Recent arrivals and what was decided (for `debug.notifications`). @ObservationIgnored private(set) var log: [String] = [] diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift new file mode 100644 index 000000000000..0dc8505452db --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift @@ -0,0 +1,117 @@ +@testable import CmuxNextApp +@testable import CmuxNextDaemon +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.approvalAnswerer = 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) + } +} From 8d46d0bc0865bd7081a2fe5466e439cac8434387 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:51:33 -0700 Subject: [PATCH 2/7] test(cx-kz6m0.2): import NotificationSource's module in the approval banner test Co-Authored-By: Claude Opus 5.5 --- .../CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift index 0dc8505452db..fe7dc5470344 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift @@ -1,5 +1,6 @@ @testable import CmuxNextApp @testable import CmuxNextDaemon +import CmuxNextSettings import Foundation import Testing From 04c964e28777ea9f2819ad9e89cb5699ed71d503 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 00:53:11 -0700 Subject: [PATCH 3/7] feat(cx-kz6m0.2): Allow once and Deny on agent chat approval banners An agent chat on this Mac that waits on one tool permission now gets a banner that says what it asks to do (tool title and command, scrubbed), with Allow once and Deny (destructive). Each button re-reads the prompt over a person-proven acpmux connection and answers it with _acpmux/permission_respond only while the same request is pending; the decision-to-option mapping is shared with the feed bridge. A prompt that ended or changed opens the tab instead. debug.notifications gains allow/deny actions and an approval field. Co-Authored-By: Claude Opus 5.5 --- .../Feed/AcpmuxPermissionFeedBridge.swift | 7 +- .../AcpmuxApprovalAnswerer.swift | 86 ++++++ .../Notifications/AcpmuxPersonLine.swift | 91 ++++++ .../Notifications/DebugNotifications.swift | 8 +- .../Notifications/DesktopNotifier.swift | 39 ++- .../NotificationCenterService+Approvals.swift | 59 +++- .../NotificationCenterService.swift | 22 +- .../Resources/Localizable.xcstrings | 264 ++++++++++++++++++ 8 files changed, 557 insertions(+), 19 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxPersonLine.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift index 92dc2937546e..4559d19578a8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift @@ -406,12 +406,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 AcpmuxPermissionAnswer.option(allow: decision.outcome == .allow, offered: offered).map { .success($0) } ?? .failure(.notOffered) } /// Whether `by` may answer for the person here: an install (not a session) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift new file mode 100644 index 000000000000..dffe1467bb0f --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift @@ -0,0 +1,86 @@ +import CmuxNextAgentPane +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 (``AcpmuxPermissionFeedBridge/digest(_:)``) is pending and +/// offers the option the decision maps to (``AcpmuxPermissionAnswer``, the feed bridge's own +/// mapping). Then `_acpmux/permission_respond`, as the chat's own Allow and Deny send it. +@MainActor +final class AcpmuxApprovalAnswerer: AgentApprovalAnswering { + private let environment: AcpmuxEnvironment + private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "notifications.approval") + + init(environment: AcpmuxEnvironment) { + self.environment = environment + } + + 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], AcpmuxPermissionAnswer.isToolPermission(request) + else { return nil } + return AgentApproval(session: session, permission: permission, digest: AcpmuxPermissionFeedBridge.digest(request), + summary: AcpmuxPermissionAnswer.summary(request)) + } + + 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], AcpmuxPermissionFeedBridge.digest(request) == approval.digest, + let option = AcpmuxPermissionAnswer.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 + } +} + +/// How the app answers an acpmux permission for the person, shared by the feed bridge (an answer +/// from the phone) and approval banners: allow maps to the offered `allow_once` option, deny to +/// the offered reject option; an option kind offered twice maps to nothing. +nonisolated enum AcpmuxPermissionAnswer { + 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, AcpmuxPermissionFeedBridge.shownCommand(input["command"])] + .compactMap { $0.map { AcpmuxPermissionFeedBridge.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 AcpmuxPermissionFeedBridge.cut(unique.joined(separator: "\n"), 300) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxPersonLine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxPersonLine.swift new file mode 100644 index 000000000000..6b8f415cf430 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxPersonLine.swift @@ -0,0 +1,91 @@ +import CmuxNextAgentPane +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 = AcpmuxPermissionFeedBridge.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 = AcpmuxPermissionFeedBridge.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/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 9a46a93f3182..e221eb855834 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift @@ -35,6 +35,12 @@ final class DesktopNotifier: NSObject { 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 that answer an agent chat's permission prompt, and its actions. + nonisolated static let approvalCategory = "cmux.notification.approval" + nonisolated static let allowAction = "cmux.notification.approval.allow-once" + nonisolated static let denyAction = "cmux.notification.approval.deny" /// 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" @@ -56,9 +62,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() @@ -77,6 +84,7 @@ final class DesktopNotifier: NSObject { content.interruptionLevel = .active if let workspace { content.threadIdentifier = workspace } if reply { content.categoryIdentifier = Self.replyCategory } + if approval { content.categoryIdentifier = Self.approvalCategory } var info: [String: Any] = ["notification": id] if let surface { info["surface"] = NSNumber(value: surface) } content.userInfo = info @@ -118,6 +126,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. @@ -137,7 +150,7 @@ final class DesktopNotifier: NSObject { } let center = UNUserNotificationCenter.current() center.delegate = self - center.setNotificationCategories([Self.replyBannerCategory()]) + center.setNotificationCategories([Self.replyBannerCategory(), Self.approvalBannerCategory()]) self.center = center if !requestedAuthorization { requestedAuthorization = true @@ -160,6 +173,20 @@ final class DesktopNotifier: NSObject { ) return UNNotificationCategory(identifier: replyCategory, actions: [action], intentIdentifiers: []) } + + /// Approval banners' category: Allow once, and Deny (destructive). An allow runs what the + /// agent asked for: the Mac must be unlocked to send one. + private static func approvalBannerCategory() -> 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]) + return UNNotificationCategory(identifier: approvalCategory, actions: [allow, deny], intentIdentifiers: []) + } } extension DesktopNotifier: UNUserNotificationCenterDelegate { @@ -176,9 +203,15 @@ 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: Bool? = switch response.actionIdentifier { + case Self.allowAction: true + case Self.denyAction: false + default: nil + } 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 index 80ebbd0755a8..7d07654650fe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift @@ -1,7 +1,62 @@ import CmuxNextDaemon +import CmuxNextSettings import Foundation +/// Approval banners (cx-kz6m0.2, parity with classic's feed permission banners): an agent chat +/// on this Mac that waits on one tool permission gets a banner that says what it asks to do, +/// with Allow once and Deny. Both answer the chat's prompt through ``approvalAnswerer``, which +/// re-reads the prompt first: an answer counts only for the request the banner showed. extension NotificationCenterService { - /// Allow once or Deny on an approval banner (cx-kz6m0.2). - func answerApproval(banner: String, surface: SurfaceID?, allow: Bool) {} + /// Wires the app's acpmux answerer, this Mac's host id, and the banners' buttons. + func followApprovals(_ services: AppServices) { + if approvalAnswerer == nil, let environment = QuitAgents.environment(services) { + approvalAnswerer = 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) + } + } + + /// The session whose prompt `notification` may stand for: an agent's ask (a warning) on an + /// agent chat tab whose session runs on this Mac's acpmux. Nil for everything else (a + /// terminal agent's hook approval has no prompt this app can answer). + func approvalSession(of notification: DaemonNotification, tab: TabModel?) -> String? { + guard approvalAnswerer != nil, notification.source == NotificationSource.agent.rawValue, + notification.level == .warning, let ref = tab?.agentSession, let session = ref.session, + let local = localAgentHost(), ref.host == local else { return nil } + return session + } + + /// Runs `post` with the chat's pending permission when `notification` stands for one, at + /// once with nil otherwise. + func withApproval(of notification: DaemonNotification, tab: TabModel?, + post: @escaping @MainActor (AgentApproval?) -> Void) { + guard let session = approvalSession(of: notification, tab: tab), let answerer = approvalAnswerer else { + return post(nil) + } + // task-owner: NotificationCenterService.withApproval: one prompt read before one banner posts. + Task { @MainActor in post(await answerer.pendingApproval(session: session)) } + } + + /// Allow once or Deny on an approval banner. Only a banner this launch posted answers, and + /// only for its own prompt (the answerer re-reads it). An answered prompt reads the tab; + /// one that ended or changed opens the tab, where the chat shows what it waits on now. + func answerApproval(banner: String, surface: SurfaceID?, allow: Bool) { + guard let entry = approvalBanners.removeValue(forKey: banner), let answerer = approvalAnswerer 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) + guard let self else { return } + self.note("approval \(allow ? "allow" : "deny") \(entry.tab) \(answered ? "answered" : "not answered")") + guard let services = self.services else { return } + if answered, let tab = Self.tab(id: entry.tab, in: services.daemon.store) { + self.acknowledge(tab) + } else if !answered { + self.open(surface: surface) + } + } + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift index b8b4b566bb9c..27ca440b9335 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift @@ -95,6 +95,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 @@ -224,7 +225,9 @@ final class NotificationCenterService { func acknowledge(_ tab: TabModel) { note("ack \(tab.id)") timeouts.removeValue(forKey: tab.id)?.cancel() - desktop.withdraw(banners.removeValue(forKey: tab.id) ?? []) + let withdrawn = banners.removeValue(forKey: tab.id) ?? [] + for id in withdrawn { approvalBanners[id] = nil } + desktop.withdraw(withdrawn) let surface = tab.surface let driver = feedDriver services?.daemon.send("ack-tab-notifications") { connection in @@ -344,12 +347,17 @@ final class NotificationCenterService { // An OSC 7501 alert reads in the app's language; the daemon's English is the fallback. let 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) } + if let approval, let tab { self.approvalBanners[id] = (approval, tab.id) } + } } /// A terminal program chose its banner's title and text (OSC 9/777/99, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings index 6b20f7ccc6cf..4850b7c791b2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings @@ -4201,6 +4201,270 @@ } } }, + "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.reply.send": { "comment": "The Send button of a notification banner's reply field: it types the text into the terminal and presses Return.", "extractionState": "manual", From 2f5efe1c161267cd81dc22c6b29638f8be479fb6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 02:30:34 -0700 Subject: [PATCH 4/7] test(cx-kz6m0.2): approval banners skip a read or superseded ask Red: a banner whose prompt read outlived an acknowledge or a newer ask still posts, and an older approval banner still answers. Co-Authored-By: Claude Opus 5.5 --- .../ApprovalBannerTests.swift | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift index fe7dc5470344..b5e048870b08 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift @@ -115,4 +115,89 @@ struct ApprovalBannerTests { 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.approvalAnswerer = 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]) + } } From f2721f429d6bb6e5103d887500955f50f1a06fe1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 02:37:03 -0700 Subject: [PATCH 5/7] fix(cx-kz6m0.2): approval banners skip stale reads and retire older buttons A banner whose prompt read outlived an acknowledge or a newer notification of its tab no longer posts; a newer banner on a tab retires the older approval banner's buttons, so the map holds one entry per tab. The approval category hides the command where macOS hides previews. Co-Authored-By: Claude Opus 5.5 --- .../Notifications/DesktopNotifier.swift | 6 +- .../NotificationCenterService+Approvals.swift | 28 +++- .../NotificationCenterService.swift | 9 +- .../Resources/Localizable.xcstrings | 132 ++++++++++++++++++ 4 files changed, 169 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift index e221eb855834..1c32f0aa6b79 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift @@ -185,7 +185,11 @@ final class DesktopNotifier: NSObject { identifier: denyAction, title: String(localized: "notifications.approval.deny", defaultValue: "Deny", bundle: .module), options: [.destructive]) - return UNNotificationCategory(identifier: approvalCategory, actions: [allow, deny], intentIdentifiers: []) + // Where macOS hides previews (locked screen, Show Previews off), the command stays hidden too. + let hidden = String(localized: "notifications.approval.hiddenBody", + defaultValue: "An agent is waiting for your approval", bundle: .module) + return UNNotificationCategory(identifier: approvalCategory, actions: [allow, deny], intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: hidden, options: []) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift index 7d07654650fe..86da474cb516 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift @@ -29,14 +29,36 @@ extension NotificationCenterService { } /// Runs `post` with the chat's pending permission when `notification` stands for one, at - /// once with nil otherwise. + /// once with nil otherwise. A read that the tab outlived posts nothing: the tab was read + /// meanwhile (focused, or answered in the chat), or a newer notification of it posted. func withApproval(of notification: DaemonNotification, tab: TabModel?, post: @escaping @MainActor (AgentApproval?) -> Void) { - guard let session = approvalSession(of: notification, tab: tab), let answerer = approvalAnswerer else { + guard let tab, let session = approvalSession(of: notification, tab: tab), let answerer = approvalAnswerer else { + if let tab { approvalReads[tab.id] = nil } return post(nil) } + let id = notification.notification.rawValue + approvalReads[tab.id] = id // task-owner: NotificationCenterService.withApproval: one prompt read before one banner posts. - Task { @MainActor in post(await answerer.pendingApproval(session: session)) } + Task { @MainActor [weak self] in + let approval = await answerer.pendingApproval(session: session) + guard let self else { return } + guard self.approvalReads[tab.id] == id else { + return self.note("approval banner \(id) skipped: \(tab.id) was read or notified again") + } + self.approvalReads[tab.id] = nil + if let marker = tab.notification, marker.notification.rawValue > id || (marker.notification.rawValue == id && !marker.unread) { + return self.note("approval banner \(id) skipped: \(tab.id) holds no unread \(id)") + } + post(approval) + } + } + + /// A banner posted on `tab`: it retires the buttons of the tab's older approval banners (one + /// chat waits on one prompt; the newer banner stands for it), and keeps its own. + func keepApprovalBanner(_ banner: String, approval: AgentApproval?, tab: String) { + approvalBanners = approvalBanners.filter { $0.value.tab != tab } + if let approval { approvalBanners[banner] = (approval, tab) } } /// Allow once or Deny on an approval banner. Only a banner this launch posted answers, and diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift index 27ca440b9335..b7e5d21e2068 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService.swift @@ -52,6 +52,8 @@ final class NotificationCenterService { @ObservationIgnored var localAgentHost: @MainActor () -> String? = { nil } /// Approval banners posted this launch, by banner id: what Allow once or Deny answers. @ObservationIgnored var approvalBanners: [String: (approval: AgentApproval, tab: String)] = [:] + /// Per tab id, the notification whose banner waits for its chat's prompt to be read. + @ObservationIgnored var approvalReads: [String: UInt64] = [:] @ObservationIgnored private var tasks: [Task] = [] /// Recent arrivals and what was decided (for `debug.notifications`). @ObservationIgnored private(set) var log: [String] = [] @@ -227,6 +229,7 @@ final class NotificationCenterService { timeouts.removeValue(forKey: tab.id)?.cancel() let withdrawn = banners.removeValue(forKey: tab.id) ?? [] for id in withdrawn { approvalBanners[id] = nil } + approvalReads[tab.id] = nil desktop.withdraw(withdrawn) let surface = tab.surface let driver = feedDriver @@ -355,8 +358,10 @@ final class NotificationCenterService { 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) } - if let approval, let tab { self.approvalBanners[id] = (approval, tab.id) } + if let tab { + self.banners[tab.id, default: []].append(id) + self.keepApprovalBanner(id, approval: approval, tab: tab.id) + } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings index 4850b7c791b2..d08e40b43829 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings @@ -4465,6 +4465,138 @@ } } }, + "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": "代理程式正在等待你的核准" + } + } + } + }, "notifications.reply.send": { "comment": "The Send button of a notification banner's reply field: it types the text into the terminal and presses Return.", "extractionState": "manual", From b893776b393e1cce61f12f1b6155ee87b2f994b1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 03:18:38 -0700 Subject: [PATCH 6/7] refactor(cx-kz6m0.2): approval banners live in CmuxNextAgentPane CmuxNextApp is size-capped (SW14). The answerer, the person-proven acpmux line, the banner bookkeeping and category, the prompt helpers the feed bridge shares (digest, frame, shown command, option mapping), and FeedSecretScrubber move to CmuxNextAgentPane, which already holds the acpmux connection types. CmuxNextApp keeps only the wiring and ends 57 lines smaller than its base. Co-Authored-By: Claude Opus 5.5 --- .../Approvals/AcpmuxApprovalAnswerer.swift | 49 +++ .../Approvals/AcpmuxPermissionPrompt.swift | 120 ++++++ .../Approvals}/AcpmuxPersonLine.swift | 5 +- .../Approvals/AgentApprovalBanners.swift | 97 +++++ .../Approvals}/FeedSecretScrubber.swift | 10 +- .../Resources/Localizable.xcstrings | 396 ++++++++++++++++++ .../Feed/AcpmuxPermissionFeedBridge.swift | 115 +---- .../CmuxNextApp/Feed/FeedHandoffPolicy.swift | 1 + .../CmuxNextApp/Feed/FeedHandoffWire.swift | 1 + .../AcpmuxApprovalAnswerer.swift | 86 ---- .../Notifications/AgentApprovalBanners.swift | 25 -- .../Notifications/DesktopNotifier.swift | 33 +- .../NotificationCenterService+Approvals.swift | 82 ++-- .../NotificationCenterService.swift | 18 +- .../Resources/Localizable.xcstrings | 396 ------------------ .../ApprovalBannerTests.swift | 5 +- 16 files changed, 725 insertions(+), 714 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxApprovalAnswerer.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift rename Packages/macOS/CmuxNext/Sources/{CmuxNextApp/Notifications => CmuxNextAgentPane/Approvals}/AcpmuxPersonLine.swift (95%) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AgentApprovalBanners.swift rename Packages/macOS/CmuxNext/Sources/{CmuxNextApp/Feed => CmuxNextAgentPane/Approvals}/FeedSecretScrubber.swift (90%) delete mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift delete mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift 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..eaeb13f85cae --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift @@ -0,0 +1,120 @@ +public 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. +public nonisolated enum AcpmuxPermissionPrompt { + /// One JSON-RPC request line. + public 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). + public 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. + public 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: " ") + } + + public 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: "'\\''") + "'" + } + + public 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. + public 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). + public 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) + } + + public static func cut(_ text: String, _ limit: Int) -> String { + text.count > limit ? String(text.prefix(limit - 1)) + "…" : text + } + + public 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. + public 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). + public 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/CmuxNextApp/Notifications/AcpmuxPersonLine.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPersonLine.swift similarity index 95% rename from Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxPersonLine.swift rename to Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPersonLine.swift index 6b8f415cf430..b106af97715f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxPersonLine.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPersonLine.swift @@ -1,4 +1,3 @@ -import CmuxNextAgentPane import CmuxNextWakeups import Foundation @@ -44,7 +43,7 @@ final class AcpmuxPersonLine { guard !closed else { return nil } nextID += 1 let id = nextID - let frame = AcpmuxPermissionFeedBridge.frame(id: id, method: method, params: params) + let frame = AcpmuxPermissionPrompt.frame(id: id, method: method, params: params) let reply: Data? = await withCheckedContinuation { waiter in waiters[id] = waiter line.send(frame) @@ -68,7 +67,7 @@ final class AcpmuxPersonLine { /// Sends `initialize` as the first request and waits for its result. private func start() async -> [String: Any]? { - let frame = AcpmuxPermissionFeedBridge.frame(id: 1, method: "initialize", params: [ + let frame = AcpmuxPermissionPrompt.frame(id: 1, method: "initialize", params: [ "protocolVersion": 1, "clientInfo": ["name": "cmux-next-approval-banner", "version": "1"], "clientCapabilities": [String: Any](), ]) 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 90% rename from Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedSecretScrubber.swift rename to Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/FeedSecretScrubber.swift index f4f9cf3a95ae..4e81f6093737 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedSecretScrubber.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/FeedSecretScrubber.swift @@ -5,8 +5,8 @@ import Foundation /// screen). A best-effort net, not a guarantee: terminal notifications stay /// off by default (`feed.mirrorNotifications.terminal`) because program /// output can hold secrets of any shape. -nonisolated enum FeedSecretScrubber { - static let marker = "[redacted]" +public nonisolated enum FeedSecretScrubber { + public static let marker = "[redacted]" /// Patterns whose whole match is a secret. private static let whole: [NSRegularExpression] = [ @@ -35,14 +35,14 @@ nonisolated enum FeedSecretScrubber { /// over unbounded program output ran on the main actor (cx-9c8m); a /// secret cut at the edge is still redacted (the PEM pattern runs to the /// end of input). - static let window = 16 * 1024 + public static let window = 16 * 1024 /// `scrub` of the first `window` characters of `text`. - static func scrubPrefix(_ text: String) -> String { + public static func scrubPrefix(_ text: String) -> String { scrub(String(text.prefix(window))) } - static func scrub(_ text: String) -> String { + public static func scrub(_ text: String) -> String { guard !text.isEmpty else { return text } var out = text for pattern in whole { 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 4559d19578a8..40509f98b7ea 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: AcpmuxPermissionPrompt.frame(id: 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(AcpmuxPermissionPrompt.frame(id: 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(AcpmuxPermissionPrompt.frame(id: 3, method: "_acpmux/watch", params: ["enabled": true])) } private func lost(_ line: AcpmuxCheckedLine) { @@ -231,23 +222,17 @@ 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 = AcpmuxPermissionPrompt.visible(FeedSecretScrubber.scrub(rawTitle)) + let kind = AcpmuxPermissionPrompt.visible(call["kind"] as? String ?? "") 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 = AcpmuxPermissionPrompt.shownCommand(input["command"]) + let command = rawCommand.map { AcpmuxPermissionPrompt.visible(FeedSecretScrubber.scrub($0)) } + let summary = AcpmuxPermissionPrompt.inputSummary(input) // 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) @@ -256,18 +241,18 @@ final class AcpmuxPermissionFeedBridge { || 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": AcpmuxPermissionPrompt.cut(title.isEmpty ? String(localized: "feed.bridge.summary", defaultValue: "An agent asks to run a tool", bundle: .module) : title, 500), ] - if !kind.isEmpty { action["tool"] = cut(kind, 200) } - if let command, !command.isEmpty { action["command"] = cut(command, 8000) } + if !kind.isEmpty { action["tool"] = AcpmuxPermissionPrompt.cut(kind, 200) } + if let command, !command.isEmpty { action["command"] = AcpmuxPermissionPrompt.cut(command, 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 \(AcpmuxPermissionPrompt.cut(title.isEmpty ? kind : title, 120))?", bundle: .module) var params: [String: Any] = [ "type": "request", "kind": "approve", - "title": cut(heading, 200), + "title": AcpmuxPermissionPrompt.cut(heading, 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"] = AcpmuxPermissionPrompt.cut(command, 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: AcpmuxPermissionPrompt.digest(request), shown: FeedApproveProofCheck.shownSHA256(action: action), truncated: action["truncated"] as? Bool ?? false) logger.info("feed bridge: posted \(id, privacy: .public) for \(permission, privacy: .public)") @@ -406,7 +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) } - return AcpmuxPermissionAnswer.option(allow: decision.outcome == .allow, offered: offered).map { .success($0) } ?? .failure(.notOffered) + return AcpmuxPermissionPrompt.option(allow: decision.outcome == .allow, offered: offered).map { .success($0) } ?? .failure(.notOffered) } /// Whether `by` may answer for the person here: an install (not a session) @@ -429,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 AcpmuxPermissionPrompt.digest(request) == entry.digest else { return logger.error("feed bridge: \(item, privacy: .public): the prompt changed since it was posted; nothing answered") } let option: String @@ -466,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..33442af10285 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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift index 7d6bdef0387f..cd511a9ec5cf 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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift deleted file mode 100644 index dffe1467bb0f..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AcpmuxApprovalAnswerer.swift +++ /dev/null @@ -1,86 +0,0 @@ -import CmuxNextAgentPane -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 (``AcpmuxPermissionFeedBridge/digest(_:)``) is pending and -/// offers the option the decision maps to (``AcpmuxPermissionAnswer``, the feed bridge's own -/// mapping). Then `_acpmux/permission_respond`, as the chat's own Allow and Deny send it. -@MainActor -final class AcpmuxApprovalAnswerer: AgentApprovalAnswering { - private let environment: AcpmuxEnvironment - private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "notifications.approval") - - init(environment: AcpmuxEnvironment) { - self.environment = environment - } - - 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], AcpmuxPermissionAnswer.isToolPermission(request) - else { return nil } - return AgentApproval(session: session, permission: permission, digest: AcpmuxPermissionFeedBridge.digest(request), - summary: AcpmuxPermissionAnswer.summary(request)) - } - - 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], AcpmuxPermissionFeedBridge.digest(request) == approval.digest, - let option = AcpmuxPermissionAnswer.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 - } -} - -/// How the app answers an acpmux permission for the person, shared by the feed bridge (an answer -/// from the phone) and approval banners: allow maps to the offered `allow_once` option, deny to -/// the offered reject option; an option kind offered twice maps to nothing. -nonisolated enum AcpmuxPermissionAnswer { - 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, AcpmuxPermissionFeedBridge.shownCommand(input["command"])] - .compactMap { $0.map { AcpmuxPermissionFeedBridge.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 AcpmuxPermissionFeedBridge.cut(unique.joined(separator: "\n"), 300) - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift deleted file mode 100644 index d499e319dc86..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/AgentApprovalBanners.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation - -/// 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. -struct AgentApproval: Sendable, Equatable { - var session: String - var permission: String - /// sha256 of the prompt's request (``AcpmuxPermissionFeedBridge/digest(_:)``): an answer - /// counts only while the same request is pending. - var digest: String - /// What the agent asks to do (its tool call's title and command, scrubbed): the banner's body. - var summary: String -} - -/// 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 -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 -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/DesktopNotifier.swift index 1c32f0aa6b79..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 @@ -37,10 +38,6 @@ final class DesktopNotifier: NSObject { 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 that answer an agent chat's permission prompt, and its actions. - nonisolated static let approvalCategory = "cmux.notification.approval" - nonisolated static let allowAction = "cmux.notification.approval.allow-once" - nonisolated static let denyAction = "cmux.notification.approval.deny" /// 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" @@ -84,7 +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 = Self.approvalCategory } + if approval { content.categoryIdentifier = AgentApprovalBanners.category } var info: [String: Any] = ["notification": id] if let surface { info["surface"] = NSNumber(value: surface) } content.userInfo = info @@ -150,7 +147,7 @@ final class DesktopNotifier: NSObject { } let center = UNUserNotificationCenter.current() center.delegate = self - center.setNotificationCategories([Self.replyBannerCategory(), Self.approvalBannerCategory()]) + center.setNotificationCategories([Self.replyBannerCategory(), AgentApprovalBanners.notificationCategory()]) self.center = center if !requestedAuthorization { requestedAuthorization = true @@ -173,24 +170,6 @@ final class DesktopNotifier: NSObject { ) return UNNotificationCategory(identifier: replyCategory, actions: [action], intentIdentifiers: []) } - - /// Approval banners' category: Allow once, and Deny (destructive). An allow runs what the - /// agent asked for: the Mac must be unlocked to send one. - private static func approvalBannerCategory() -> 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]) - // Where macOS hides previews (locked screen, Show Previews off), the command stays hidden too. - let hidden = String(localized: "notifications.approval.hiddenBody", - defaultValue: "An agent is waiting for your approval", bundle: .module) - return UNNotificationCategory(identifier: approvalCategory, actions: [allow, deny], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: hidden, options: []) - } } extension DesktopNotifier: UNUserNotificationCenterDelegate { @@ -207,11 +186,7 @@ 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: Bool? = switch response.actionIdentifier { - case Self.allowAction: true - case Self.denyAction: false - default: 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) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift index 86da474cb516..be898dbabbd7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Notifications/NotificationCenterService+Approvals.swift @@ -1,16 +1,13 @@ +import CmuxNextAgentPane import CmuxNextDaemon import CmuxNextSettings -import Foundation -/// Approval banners (cx-kz6m0.2, parity with classic's feed permission banners): an agent chat -/// on this Mac that waits on one tool permission gets a banner that says what it asks to do, -/// with Allow once and Deny. Both answer the chat's prompt through ``approvalAnswerer``, which -/// re-reads the prompt first: an answer counts only for the request the banner showed. +/// 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 { - /// Wires the app's acpmux answerer, this Mac's host id, and the banners' buttons. func followApprovals(_ services: AppServices) { - if approvalAnswerer == nil, let environment = QuitAgents.environment(services) { - approvalAnswerer = AcpmuxApprovalAnswerer(environment: environment) + 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 @@ -18,67 +15,36 @@ extension NotificationCenterService { } } - /// The session whose prompt `notification` may stand for: an agent's ask (a warning) on an - /// agent chat tab whose session runs on this Mac's acpmux. Nil for everything else (a - /// terminal agent's hook approval has no prompt this app can answer). - func approvalSession(of notification: DaemonNotification, tab: TabModel?) -> String? { - guard approvalAnswerer != nil, notification.source == NotificationSource.agent.rawValue, - notification.level == .warning, let ref = tab?.agentSession, let session = ref.session, - let local = localAgentHost(), ref.host == local else { return nil } - return session - } - - /// Runs `post` with the chat's pending permission when `notification` stands for one, at - /// once with nil otherwise. A read that the tab outlived posts nothing: the tab was read - /// meanwhile (focused, or answered in the chat), or a newer notification of it posted. - func withApproval(of notification: DaemonNotification, tab: TabModel?, - post: @escaping @MainActor (AgentApproval?) -> Void) { - guard let tab, let session = approvalSession(of: notification, tab: tab), let answerer = approvalAnswerer else { - if let tab { approvalReads[tab.id] = nil } + /// 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 - approvalReads[tab.id] = id - // task-owner: NotificationCenterService.withApproval: one prompt read before one banner posts. - Task { @MainActor [weak self] in - let approval = await answerer.pendingApproval(session: session) - guard let self else { return } - guard self.approvalReads[tab.id] == id else { - return self.note("approval banner \(id) skipped: \(tab.id) was read or notified again") - } - self.approvalReads[tab.id] = nil - if let marker = tab.notification, marker.notification.rawValue > id || (marker.notification.rawValue == id && !marker.unread) { - return self.note("approval banner \(id) skipped: \(tab.id) holds no unread \(id)") - } - post(approval) - } - } - - /// A banner posted on `tab`: it retires the buttons of the tab's older approval banners (one - /// chat waits on one prompt; the newer banner stands for it), and keeps its own. - func keepApprovalBanner(_ banner: String, approval: AgentApproval?, tab: String) { - approvalBanners = approvalBanners.filter { $0.value.tab != tab } - if let approval { approvalBanners[banner] = (approval, tab) } + 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. Only a banner this launch posted answers, and - /// only for its own prompt (the answerer re-reads it). An answered prompt reads the tab; - /// one that ended or changed opens the tab, where the chat shows what it waits on now. + /// 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 = approvalBanners.removeValue(forKey: banner), let answerer = approvalAnswerer else { + 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) - guard let self else { return } - self.note("approval \(allow ? "allow" : "deny") \(entry.tab) \(answered ? "answered" : "not answered")") - guard let services = self.services else { return } - if answered, let tab = Self.tab(id: entry.tab, in: services.daemon.store) { - self.acknowledge(tab) - } else if !answered { - self.open(surface: surface) - } + 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 b7e5d21e2068..5a15fd090671 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 @@ -46,14 +47,9 @@ final class NotificationCenterService { /// Agent permission prompts to the person's feed, while /// `feed.agentPermissionPrompts` is on (cx-aocz). @ObservationIgnored var permissionBridge: AcpmuxPermissionFeedBridge? - /// Reads and answers agent chat prompts for approval banners (cx-kz6m0.2); tests stand in. - @ObservationIgnored var approvalAnswerer: (any AgentApprovalAnswering)? - /// This Mac's `install:`: only a chat whose session runs here gets Allow once and Deny. + /// Agent chat approval banners (cx-kz6m0.2) and this Mac's `install:` they need. + @ObservationIgnored let approvals = AgentApprovalBanners() @ObservationIgnored var localAgentHost: @MainActor () -> String? = { nil } - /// Approval banners posted this launch, by banner id: what Allow once or Deny answers. - @ObservationIgnored var approvalBanners: [String: (approval: AgentApproval, tab: String)] = [:] - /// Per tab id, the notification whose banner waits for its chat's prompt to be read. - @ObservationIgnored var approvalReads: [String: UInt64] = [:] @ObservationIgnored private var tasks: [Task] = [] /// Recent arrivals and what was decided (for `debug.notifications`). @ObservationIgnored private(set) var log: [String] = [] @@ -227,10 +223,8 @@ final class NotificationCenterService { func acknowledge(_ tab: TabModel) { note("ack \(tab.id)") timeouts.removeValue(forKey: tab.id)?.cancel() - let withdrawn = banners.removeValue(forKey: tab.id) ?? [] - for id in withdrawn { approvalBanners[id] = nil } - approvalReads[tab.id] = nil - desktop.withdraw(withdrawn) + approvals.forget(tab: tab.id, retire: true) + desktop.withdraw(banners.removeValue(forKey: tab.id) ?? []) let surface = tab.surface let driver = feedDriver services?.daemon.send("ack-tab-notifications") { connection in @@ -360,7 +354,7 @@ final class NotificationCenterService { if let sound, sound != "default" { NotificationSounds.play(sound) } if let tab { self.banners[tab.id, default: []].append(id) - self.keepApprovalBanner(id, approval: approval, tab: tab.id) + self.approvals.keep(id, approval: approval, tab: tab.id) } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings index d08e40b43829..6b20f7ccc6cf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Resources/Localizable.xcstrings @@ -4201,402 +4201,6 @@ } } }, - "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": "代理程式正在等待你的核准" - } - } - } - }, "notifications.reply.send": { "comment": "The Send button of a notification banner's reply field: it types the text into the terminal and presses Return.", "extractionState": "manual", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift index b5e048870b08..f3023db24e0f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ApprovalBannerTests.swift @@ -1,3 +1,4 @@ +import CmuxNextAgentPane @testable import CmuxNextApp @testable import CmuxNextDaemon import CmuxNextSettings @@ -46,7 +47,7 @@ struct ApprovalBannerTests { 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.approvalAnswerer = answerer + service.approvals.answerer = answerer service.localAgentHost = { Self.thisMac } var decision = NotificationPolicy.Decision() decision.desktop = true @@ -145,7 +146,7 @@ struct ApprovalBannerTests { let workspace = try #require(store.workspaces.first) let pane = try #require(workspace.screens.first?.panes.first) let service = NotificationCenterService() - service.approvalAnswerer = answerer + service.approvals.answerer = answerer service.localAgentHost = { Self.thisMac } return (service, LocatedTab(tab: try #require(pane.tabs.first), pane: pane, workspace: workspace)) } From ca386d58c1d0fc7ec846a1727bc98c8f2e77d088 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 15 Oct 2026 03:39:05 -0700 Subject: [PATCH 7/7] refactor(cx-kz6m0.2): prompt and scrubber helpers as extensions, not public namespaces package-conventions-lint refuses all-static public enums. The prompt helpers and FeedSecretScrubber stay internal to CmuxNextAgentPane; the app reaches them through extensions on the request dictionary, offered options, String and Data. Co-Authored-By: Claude Opus 5.5 --- .../Approvals/AcpmuxPermissionPrompt.swift | 26 ++++++------- .../Approvals/AcpmuxPrompt+Text.swift | 36 ++++++++++++++++++ .../Approvals/FeedSecretScrubber.swift | 10 ++--- .../Feed/AcpmuxPermissionFeedBridge.swift | 38 +++++++++---------- .../CmuxNextApp/Feed/FeedHandoffPolicy.swift | 2 +- .../CmuxNextApp/Feed/FeedHandoffWire.swift | 4 +- 6 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPrompt+Text.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift index eaeb13f85cae..102f58ca3766 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/AcpmuxPermissionPrompt.swift @@ -1,13 +1,13 @@ -public import Foundation +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. -public nonisolated enum AcpmuxPermissionPrompt { +nonisolated enum AcpmuxPermissionPrompt { /// One JSON-RPC request line. - public static func frame(id: Int, method: String, params: [String: Any]) -> Data { + 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) @@ -15,7 +15,7 @@ public nonisolated enum AcpmuxPermissionPrompt { } /// sha256 of the request's canonical JSON (sorted keys). - public static func digest(_ request: [String: Any]) -> String { + 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() } @@ -23,28 +23,28 @@ public nonisolated enum AcpmuxPermissionPrompt { /// 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. - public static func shownCommand(_ value: Any?) -> String? { + 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: " ") } - public static func shellQuoted(_ word: String) -> String { + 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: "'\\''") + "'" } - public static func compactJSON(_ value: Any) -> String { + 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. - public static func visible(_ text: String) -> String { + static func visible(_ text: String) -> String { var out = "" for scalar in text.unicodeScalars { switch scalar.value { @@ -61,7 +61,7 @@ public nonisolated enum AcpmuxPermissionPrompt { /// 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). - public static func inputSummary(_ input: [String: Any]) -> (fields: [String: String], truncated: Bool)? { + 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 @@ -82,11 +82,11 @@ public nonisolated enum AcpmuxPermissionPrompt { return out.isEmpty && !truncated ? nil : (out, truncated) } - public static func cut(_ text: String, _ limit: Int) -> String { + static func cut(_ text: String, _ limit: Int) -> String { text.count > limit ? String(text.prefix(limit - 1)) + "…" : text } - public static func option(allow: Bool, offered: [[String: Any]]) -> String? { + 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 } @@ -97,7 +97,7 @@ public nonisolated enum AcpmuxPermissionPrompt { /// 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. - public static func isToolPermission(_ request: [String: Any]) -> Bool { + 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]] ?? [] @@ -107,7 +107,7 @@ public nonisolated enum AcpmuxPermissionPrompt { /// 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). - public static func summary(_ request: [String: Any]) -> String { + 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"])] 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/FeedSecretScrubber.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/FeedSecretScrubber.swift index 4e81f6093737..f4f9cf3a95ae 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/FeedSecretScrubber.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Approvals/FeedSecretScrubber.swift @@ -5,8 +5,8 @@ import Foundation /// screen). A best-effort net, not a guarantee: terminal notifications stay /// off by default (`feed.mirrorNotifications.terminal`) because program /// output can hold secrets of any shape. -public nonisolated enum FeedSecretScrubber { - public static let marker = "[redacted]" +nonisolated enum FeedSecretScrubber { + static let marker = "[redacted]" /// Patterns whose whole match is a secret. private static let whole: [NSRegularExpression] = [ @@ -35,14 +35,14 @@ public nonisolated enum FeedSecretScrubber { /// over unbounded program output ran on the main actor (cx-9c8m); a /// secret cut at the edge is still redacted (the PEM pattern runs to the /// end of input). - public static let window = 16 * 1024 + static let window = 16 * 1024 /// `scrub` of the first `window` characters of `text`. - public static func scrubPrefix(_ text: String) -> String { + static func scrubPrefix(_ text: String) -> String { scrub(String(text.prefix(window))) } - public static func scrub(_ text: String) -> String { + static func scrub(_ text: String) -> String { guard !text.isEmpty else { return text } var out = text for pattern in whole { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift index 40509f98b7ea..9f3ee13f85d5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/AcpmuxPermissionFeedBridge.swift @@ -114,7 +114,7 @@ final class AcpmuxPermissionFeedBridge { guard FileManager.default.fileExists(atPath: socket) else { return scheduleReconnect() } let line = AcpmuxCheckedLine(environment: environment) connection = line - line.start(send: AcpmuxPermissionPrompt.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) } }, @@ -127,11 +127,11 @@ final class AcpmuxPermissionFeedBridge { 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(AcpmuxPermissionPrompt.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(AcpmuxPermissionPrompt.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) { @@ -227,32 +227,32 @@ final class AcpmuxPermissionFeedBridge { 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 = AcpmuxPermissionPrompt.visible(FeedSecretScrubber.scrub(rawTitle)) - let kind = AcpmuxPermissionPrompt.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 = AcpmuxPermissionPrompt.shownCommand(input["command"]) - let command = rawCommand.map { AcpmuxPermissionPrompt.visible(FeedSecretScrubber.scrub($0)) } - let summary = AcpmuxPermissionPrompt.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": AcpmuxPermissionPrompt.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"] = AcpmuxPermissionPrompt.cut(kind, 200) } - if let command, !command.isEmpty { action["command"] = AcpmuxPermissionPrompt.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 \(AcpmuxPermissionPrompt.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": AcpmuxPermissionPrompt.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))], @@ -260,7 +260,7 @@ 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"] = AcpmuxPermissionPrompt.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"] } @@ -279,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: AcpmuxPermissionPrompt.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)") @@ -329,7 +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) } - return AcpmuxPermissionPrompt.option(allow: decision.outcome == .allow, offered: offered).map { .success($0) } ?? .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) @@ -352,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 AcpmuxPermissionPrompt.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 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift index 33442af10285..af3c14d1b673 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffPolicy.swift @@ -48,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 cd511a9ec5cf..7272b765a046 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Feed/FeedHandoffWire.swift @@ -33,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) }),