Files
openclaw/apps/macos/Sources/OpenClaw/ExecApprovals.swift
Peter Steinberger 2b0da0e193 refactor(macos): single-owner hybrid status menu with live session cards and exec approvals (#130041)
* 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.
2026-08-26 06:16:16 -07:00

319 lines
9.6 KiB
Swift

import Foundation
import OpenClawKit
typealias ExecSecurity = ExecApprovalsSecurity
typealias ExecAsk = ExecApprovalsAsk
typealias ExecAllowlistEntry = ExecApprovalsAllowlistEntry
typealias ExecApprovalsDefaults = ExecApprovalsDefaultsDocument
typealias ExecApprovalsAgent = ExecApprovalsAgentDocument
typealias ExecApprovalsSocketConfig = ExecApprovalsSocketDocument
typealias ExecApprovalsFile = ExecApprovalsDocument
extension ExecApprovalsSecurity {
var title: String {
switch self {
case .deny: "Deny"
case .allowlist: "Allowlist"
case .full: "Always Allow"
}
}
static func narrower(_ lhs: ExecSecurity, _ rhs: ExecSecurity) -> ExecSecurity {
if lhs == .deny || rhs == .deny {
return .deny
}
if lhs == .allowlist || rhs == .allowlist {
return .allowlist
}
return .full
}
}
extension ExecApprovalsAsk {
var title: String {
switch self {
case .off: "Never Ask"
case .onMiss: "Ask on Allowlist Miss"
case .always: "Always Ask"
}
}
static func stricter(_ lhs: ExecAsk, _ rhs: ExecAsk) -> ExecAsk {
lhs.strictnessRank >= rhs.strictnessRank ? lhs : rhs
}
private var strictnessRank: Int {
switch self {
case .off: 0
case .onMiss: 1
case .always: 2
}
}
}
enum ExecApprovalDecision: String, Codable, Equatable {
case allowOnce = "allow-once"
case allowAlways = "allow-always"
case deny
}
enum ExecAllowlistPatternValidationReason: String, Codable, Equatable, Sendable {
case empty
case missingPathComponent
var message: String {
switch self {
case .empty:
"Pattern cannot be empty."
case .missingPathComponent:
"Path patterns only. Include '/', '~', or '\\\\'."
}
}
}
enum ExecAllowlistPatternValidation: Equatable {
case valid(String)
case invalid(ExecAllowlistPatternValidationReason)
}
struct ExecAllowlistRejectedEntry: Equatable {
let id: String
let pattern: String
let reason: ExecAllowlistPatternValidationReason
}
struct ExecAllowlistUse: Sendable {
let match: ExecAllowlistEntry
let resolvedPath: String?
}
struct ExecAllowlistEntryMatchKey: Hashable, Sendable {
let pattern: Data
let argPattern: Data
init(pattern: String, argPattern: String?) {
self.pattern = Data(pattern.utf8)
self.argPattern = Data((argPattern ?? "").utf8)
}
}
struct ExecApprovalsSnapshot: Codable, Sendable {
var path: String
var exists: Bool
var hash: String
var file: ExecApprovalsFile
}
enum ExecApprovalsConditionalSaveResult {
case saved(ExecApprovalsSnapshot)
case baseHashUnavailable
case baseHashRequired
case conflict
case unavailable
}
enum ExecApprovalsMutationError: Error, Equatable, Sendable {
case invalidPattern(ExecAllowlistPatternValidationReason)
case entryNotOwned
case unavailable
var message: String {
switch self {
case let .invalidPattern(reason):
reason.message
case .entryNotOwned:
"This allowlist entry is inherited. Edit its owning scope and retry."
case .unavailable:
"Could not save exec approvals. Last known settings are shown; retry the change."
}
}
}
enum ExecApprovalsReadError: Error, Equatable, Sendable {
case migrationRequired(ExecApprovalsLegacyMigrationRequiredError)
case unavailable
var message: String {
switch self {
case let .migrationRequired(error):
"Exec approvals need migration — run openclaw doctor --fix with " +
"OPENCLAW_STATE_DIR set to \(error.stateDirectoryURL.path)."
case .unavailable:
"Exec approvals unavailable. Retry to refresh."
}
}
}
struct ExecApprovalsResolved: Sendable {
let url: URL
let socketPath: String
let token: String
let defaults: ExecApprovalsResolvedDefaults
let agent: ExecApprovalsResolvedDefaults
let allowlist: [ExecAllowlistEntry]
var file: ExecApprovalsFile
}
struct ExecApprovalsResolvedDefaults: Codable, Sendable {
var security: ExecSecurity
var ask: ExecAsk
var askFallback: ExecSecurity
var autoAllowSkills: Bool
}
enum ExecApprovalHelpers {
static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return .invalid(.empty) }
return .valid(trimmed)
}
static func isValidAllowlistPattern(_ pattern: String?) -> Bool {
switch self.validateAllowlistPattern(pattern) {
case .valid:
true
case .invalid:
false
}
}
static func isPathPattern(_ pattern: String?) -> Bool {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return self.patternHasPathSelector(trimmed)
}
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
return ExecApprovalDecision(rawValue: trimmed)
}
static func requiresAsk(
ask: ExecAsk,
security: ExecSecurity,
allowlistMatch: ExecAllowlistEntry?,
skillAllow: Bool) -> Bool
{
if ask == .always {
return true
}
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow {
return true
}
return false
}
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
let pattern = resolution?.resolvedRealPath ?? resolution?.resolvedPath ?? resolution?.rawExecutable ??
command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
static func patternHasPathSelector(_ pattern: String) -> Bool {
pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
}
}
actor SkillBinsCache {
static let shared = SkillBinsCache()
private var bins: Set<String> = []
private var trustByName: [String: Set<String>] = [:]
private var lastRefresh: Date?
private let refreshInterval: TimeInterval = 90
func currentBins(force: Bool = false) async -> Set<String> {
if force || self.isStale() {
await self.refresh()
}
return self.bins
}
func currentTrust(force: Bool = false) async -> [String: Set<String>] {
if force || self.isStale() {
await self.refresh()
}
return self.trustByName
}
func refresh() async {
do {
let report = try await GatewayConnection.shared.skillsStatus()
let trust = Self.buildTrustIndex(report: report, searchPaths: CommandResolver.preferredPaths())
self.bins = trust.names
self.trustByName = trust.pathsByName
self.lastRefresh = Date()
} catch {
if self.lastRefresh == nil {
self.bins = []
self.trustByName = [:]
}
}
}
static func normalizeSkillBinName(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
static func normalizeResolvedPath(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
return URL(fileURLWithPath: trimmed).resolvingSymlinksInPath().standardizedFileURL.path
}
static func buildTrustIndex(
report: SkillsStatusReport,
searchPaths: [String]) -> SkillBinTrustIndex
{
var names = Set<String>()
var pathsByName: [String: Set<String>] = [:]
for skill in report.skills {
for bin in skill.requirements.bins {
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
names.insert(trimmed)
guard let name = normalizeSkillBinName(trimmed),
let resolvedPath = resolveSkillBinPath(trimmed, searchPaths: searchPaths),
let normalizedPath = normalizeResolvedPath(resolvedPath)
else {
continue
}
var paths = pathsByName[name] ?? Set<String>()
paths.insert(normalizedPath)
pathsByName[name] = paths
}
}
return SkillBinTrustIndex(names: names, pathsByName: pathsByName)
}
private static func resolveSkillBinPath(_ bin: String, searchPaths: [String]) -> String? {
let expanded = bin.hasPrefix("~") ? (bin as NSString).expandingTildeInPath : bin
if expanded.contains("/") || expanded.contains("\\") {
return FileManager().isExecutableFile(atPath: expanded) ? expanded : nil
}
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}
private func isStale() -> Bool {
guard let lastRefresh else { return true }
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
}
static func _testBuildTrustIndex(
report: SkillsStatusReport,
searchPaths: [String]) -> SkillBinTrustIndex
{
self.buildTrustIndex(report: report, searchPaths: searchPaths)
}
}
struct SkillBinTrustIndex {
let names: Set<String>
let pathsByName: [String: Set<String>]
}