mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -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.
319 lines
9.6 KiB
Swift
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>]
|
|
}
|