mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
2b0da0e193
* test(macos): add OPENCLAW_DEBUG_OPEN_MENU screenshot hook * feat(macos): add live execution approval queue * refactor(macos): replace menu injectors with owned status menu * fix(macos): keep unconfigured menu header calm * fix(macos): route status-item right-clicks through a local event monitor NSControl's send-action mask ignores right mouse buttons, so the previous sendAction(on: [.rightMouseUp]) wiring never fired and the menu was unreachable by pointer. A local monitor now owns pointer routing (left = dashboard, right = menu) — the same mechanism the shipped StatusItemMouseRouter used — and menuWillOpen gained the re-entrancy guard the old injector carried, since reconciling tracked rows can re-enter the callback without a close. * chore(i18n): refresh native inventory for status menu strings * chore(macos): remove menu-refactor dead code Periphery flagged the orphans the status-menu refactor left behind: the ExecApprovalQuickMode enum and AppState's entire quick-mode read/retry surface (its only consumer was the deleted menu picker; the Settings pane owns exec-approval policy UI), SessionMenuLabelView, TrackingAreaSupport, NodeMenuMultilineView, UpdateStatus.disabled, and two fixture-only initializers. StatusMenuController.stop() is now wired into applicationWillTerminate. The menu-highlight environment key moved from the deleted view file into MenuItemHighlightColors. * chore(macos): fix status-menu lint style and refresh i18n inventory * fix(macos): converge approval cards after losing a resolution race The status-menu queue and the modal prompter intentionally share the gateway approval event stream: the gateway resolves each approval exactly once, the resolved broadcast removes the card, and the modal stays the active presentation owner while the menu is the passive, ambient one. What was missing: when the menu's resolve loses the race (modal or another client answered first), the gateway rejection left a zombie card if the resolved event was dropped. Resolve failures now re-list from the authoritative queue. Regression test simulates the race at the socket boundary and fails pre-fix.
245 lines
8.7 KiB
Swift
245 lines
8.7 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OpenClawKit
|
|
import OpenClawProtocol
|
|
import OSLog
|
|
|
|
struct ExecApprovalQueueItem: Decodable, Identifiable {
|
|
enum ApprovalKind {
|
|
case exec
|
|
case systemAgent
|
|
}
|
|
|
|
let id: String
|
|
let request: ExecApprovalPromptRequest
|
|
let createdAtMs: Int
|
|
let expiresAtMs: Int
|
|
let kind: ApprovalKind
|
|
let allowedDecisions: [ExecApprovalDecision]
|
|
|
|
init(
|
|
id: String,
|
|
request: ExecApprovalPromptRequest,
|
|
createdAtMs: Int,
|
|
expiresAtMs: Int,
|
|
kind: ApprovalKind = .exec)
|
|
{
|
|
self.id = id
|
|
self.request = request
|
|
self.createdAtMs = createdAtMs
|
|
self.expiresAtMs = expiresAtMs
|
|
self.kind = kind
|
|
self.allowedDecisions = Self.inlineDecisions(
|
|
request.allowedDecisions,
|
|
policyPresent: request.allowedDecisions != nil)
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let request = try container.decode(ExecApprovalPromptRequest.self, forKey: .request)
|
|
let requestContainer = try container.nestedContainer(
|
|
keyedBy: RequestCodingKeys.self,
|
|
forKey: .request)
|
|
self.id = try container.decode(String.self, forKey: .id)
|
|
self.request = request
|
|
self.createdAtMs = try container.decode(Int.self, forKey: .createdAtMs)
|
|
self.expiresAtMs = try container.decode(Int.self, forKey: .expiresAtMs)
|
|
self.kind = .exec
|
|
self.allowedDecisions = Self.inlineDecisions(
|
|
request.allowedDecisions,
|
|
policyPresent: requestContainer.contains(.allowedDecisions))
|
|
}
|
|
|
|
func assigningKind(_ kind: ApprovalKind) -> Self {
|
|
var request = self.request
|
|
request.allowedDecisions = self.allowedDecisions
|
|
return Self(
|
|
id: self.id,
|
|
request: request,
|
|
createdAtMs: self.createdAtMs,
|
|
expiresAtMs: self.expiresAtMs,
|
|
kind: kind)
|
|
}
|
|
|
|
private static func inlineDecisions(
|
|
_ decisions: [ExecApprovalDecision]?,
|
|
policyPresent: Bool) -> [ExecApprovalDecision]
|
|
{
|
|
let allowed = policyPresent ? decisions ?? [] : [.allowOnce, .deny]
|
|
return allowed.filter { $0 == .deny || $0 == .allowOnce }
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case request
|
|
case createdAtMs
|
|
case expiresAtMs
|
|
}
|
|
|
|
private enum RequestCodingKeys: String, CodingKey {
|
|
case allowedDecisions
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class ExecApprovalQueueStore {
|
|
static let shared = ExecApprovalQueueStore()
|
|
|
|
private(set) var requests: [ExecApprovalQueueItem] = []
|
|
|
|
@ObservationIgnored private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.queue")
|
|
@ObservationIgnored private let gateway: GatewayConnection
|
|
@ObservationIgnored private var eventTask: Task<Void, Never>?
|
|
@ObservationIgnored private var expiryTasks: [String: Task<Void, Never>] = [:]
|
|
@ObservationIgnored private var refreshGeneration: UInt64 = 0
|
|
|
|
init(gateway: GatewayConnection = .shared) {
|
|
self.gateway = gateway
|
|
}
|
|
|
|
func start() {
|
|
guard self.eventTask == nil else { return }
|
|
self.eventTask = Task { [weak self, gateway] in
|
|
for await push in await gateway.subscribe(bufferingNewest: 200) {
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.handle(push: push)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
self.eventTask?.cancel()
|
|
self.eventTask = nil
|
|
for task in self.expiryTasks.values {
|
|
task.cancel()
|
|
}
|
|
self.expiryTasks.removeAll()
|
|
self.refreshGeneration &+= 1
|
|
}
|
|
|
|
func refresh() async {
|
|
let generation = self.refreshGeneration
|
|
do {
|
|
let listed: [ExecApprovalQueueItem] = try await self.gateway.requestDecoded(
|
|
method: .execApprovalList,
|
|
timeoutMs: 10000)
|
|
// A resolution received during the request owns newer state; an old
|
|
// list must never resurrect its already-dismissed approval card.
|
|
guard generation == self.refreshGeneration, !Task.isCancelled else { return }
|
|
let nowMs = Self.currentTimeMs()
|
|
let systemApprovals = self.requests.filter { $0.kind == .systemAgent && $0.expiresAtMs > nowMs }
|
|
self.replaceRequests(listed.filter { $0.expiresAtMs > nowMs } + systemApprovals)
|
|
} catch {
|
|
guard !Task.isCancelled else { return }
|
|
self.logger.error("exec approval listing failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
func resolve(request: ExecApprovalQueueItem, decision: ExecApprovalDecision) async {
|
|
guard request.allowedDecisions.contains(decision),
|
|
decision != .allowAlways,
|
|
self.requests.contains(where: { $0.id == request.id })
|
|
else { return }
|
|
|
|
var params: [String: AnyCodable] = [
|
|
"id": AnyCodable(request.id),
|
|
"decision": AnyCodable(decision.rawValue),
|
|
]
|
|
let method: GatewayConnection.Method
|
|
switch request.kind {
|
|
case .exec:
|
|
method = .execApprovalResolve
|
|
case .systemAgent:
|
|
method = .approvalResolve
|
|
params["kind"] = AnyCodable("system-agent")
|
|
}
|
|
|
|
do {
|
|
try await self.gateway.requestVoid(method: method, params: params, timeoutMs: 10000)
|
|
self.removeRequest(id: request.id)
|
|
} catch {
|
|
self.logger.error("exec approval resolution failed \(error.localizedDescription, privacy: .public)")
|
|
// A losing race (the modal prompter or another client resolved first)
|
|
// surfaces here as a gateway rejection. Re-list instead of parsing
|
|
// error text so the card converges to the authoritative queue.
|
|
await self.refresh()
|
|
}
|
|
}
|
|
|
|
private func handle(push: GatewayPush) {
|
|
guard case let .event(event) = push, let payload = event.payload else { return }
|
|
switch event.event {
|
|
case "exec.approval.requested", "openclaw.approval.requested":
|
|
do {
|
|
let request = try GatewayPayloadDecoding.decode(payload, as: ExecApprovalQueueItem.self)
|
|
let kind: ExecApprovalQueueItem.ApprovalKind = event.event == "openclaw.approval.requested"
|
|
? .systemAgent
|
|
: .exec
|
|
self.insertRequest(request.assigningKind(kind))
|
|
} catch {
|
|
self.logger.error("exec approval event decode failed \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
case "exec.approval.resolved", "openclaw.approval.resolved":
|
|
guard let resolved = try? GatewayPayloadDecoding.decode(payload, as: ResolvedApproval.self) else {
|
|
return
|
|
}
|
|
self.removeRequest(id: resolved.id)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func insertRequest(_ request: ExecApprovalQueueItem) {
|
|
guard request.expiresAtMs > Self.currentTimeMs() else { return }
|
|
self.refreshGeneration &+= 1
|
|
self.requests.removeAll { $0.id == request.id }
|
|
self.requests.append(request)
|
|
self.requests.sort { $0.createdAtMs < $1.createdAtMs }
|
|
self.scheduleExpiry(for: request)
|
|
}
|
|
|
|
private func removeRequest(id: String) {
|
|
self.refreshGeneration &+= 1
|
|
self.requests.removeAll { $0.id == id }
|
|
self.expiryTasks.removeValue(forKey: id)?.cancel()
|
|
}
|
|
|
|
private func replaceRequests(_ requests: [ExecApprovalQueueItem]) {
|
|
for task in self.expiryTasks.values {
|
|
task.cancel()
|
|
}
|
|
self.expiryTasks.removeAll()
|
|
self.requests = requests.sorted { $0.createdAtMs < $1.createdAtMs }
|
|
for request in self.requests {
|
|
self.scheduleExpiry(for: request)
|
|
}
|
|
}
|
|
|
|
private func scheduleExpiry(for request: ExecApprovalQueueItem) {
|
|
self.expiryTasks.removeValue(forKey: request.id)?.cancel()
|
|
let (remainingMs, overflow) = request.expiresAtMs.subtractingReportingOverflow(Self.currentTimeMs())
|
|
guard !overflow, remainingMs > 0 else {
|
|
self.removeRequest(id: request.id)
|
|
return
|
|
}
|
|
self.expiryTasks[request.id] = Task { [weak self] in
|
|
do {
|
|
try await Task.sleep(for: .milliseconds(remainingMs))
|
|
} catch {
|
|
return
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
self?.removeRequest(id: request.id)
|
|
}
|
|
}
|
|
|
|
private static func currentTimeMs() -> Int {
|
|
Int(Date().timeIntervalSince1970 * 1000)
|
|
}
|
|
|
|
private struct ResolvedApproval: Decodable {
|
|
let id: String
|
|
}
|
|
}
|