fix: land contributor PR #39516 from @Imhermes1

macOS app/chat/browser/cron/permissions fixes.

Co-authored-by: ImHermes1 <lukeforn@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-08 06:11:20 +00:00
parent 05217845a7
commit d15b6af77b
22 changed files with 1202 additions and 64 deletions

View File

@@ -110,6 +110,44 @@ actor GatewayConnection {
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk?
private struct LossyDecodable<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws {
do {
self.value = try Value(from: decoder)
} catch {
self.value = nil
}
}
}
private struct LossyCronListResponse: Decodable {
let jobs: [LossyDecodable<CronJob>]
enum CodingKeys: String, CodingKey {
case jobs
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
}
}
private struct LossyCronRunsResponse: Decodable {
let entries: [LossyDecodable<CronRunLogEntry>]
enum CodingKeys: String, CodingKey {
case entries
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
}
}
init(
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
sessionBox: WebSocketSessionBox? = nil)
@@ -703,17 +741,17 @@ extension GatewayConnection {
}
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
let res: CronListResponse = try await self.requestDecoded(
let data = try await self.requestRaw(
method: .cronList,
params: ["includeDisabled": AnyCodable(includeDisabled)])
return res.jobs
return try Self.decodeCronListResponse(data)
}
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
let res: CronRunsResponse = try await self.requestDecoded(
let data = try await self.requestRaw(
method: .cronRuns,
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
return res.entries
return try Self.decodeCronRunsResponse(data)
}
func cronRun(jobId: String, force: Bool = true) async throws {
@@ -739,4 +777,24 @@ extension GatewayConnection {
func cronAdd(payload: [String: AnyCodable]) async throws {
try await self.requestVoid(method: .cronAdd, params: payload)
}
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
let jobs = decoded.jobs.compactMap(\.value)
let skipped = decoded.jobs.count - jobs.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
}
return jobs
}
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
let entries = decoded.entries.compactMap(\.value)
let skipped = decoded.entries.count - entries.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
}
return entries
}
}

View File

@@ -614,6 +614,44 @@ actor GatewayEndpointStore {
}
extension GatewayEndpointStore {
static func localConfig() -> GatewayConnection.Config {
self.localConfig(
root: OpenClawConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot(),
tailscaleIP: TailscaleService.fallbackTailnetIPv4())
}
static func localConfig(
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?,
tailscaleIP: String?) -> GatewayConnection.Config
{
let port = GatewayEnvironment.gatewayPort()
let bind = self.resolveGatewayBindMode(root: root, env: env)
let customBindHost = self.resolveGatewayCustomBindHost(root: root)
let scheme = self.resolveGatewayScheme(root: root, env: env)
let host = self.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
let token = self.resolveGatewayToken(
isRemote: false,
root: root,
env: env,
launchdSnapshot: launchdSnapshot)
let password = self.resolveGatewayPassword(
isRemote: false,
root: root,
env: env,
launchdSnapshot: launchdSnapshot)
return (
url: URL(string: "\(scheme)://\(host):\(port)")!,
token: token,
password: password)
}
private static func normalizeDashboardPath(_ rawPath: String?) -> String {
let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "/" }
@@ -721,5 +759,18 @@ extension GatewayEndpointStore {
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
}
static func _testLocalConfig(
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil,
tailscaleIP: String? = nil) -> GatewayConnection.Config
{
self.localConfig(
root: root,
env: env,
launchdSnapshot: launchdSnapshot,
tailscaleIP: tailscaleIP)
}
}
#endif

View File

@@ -0,0 +1,234 @@
import Foundation
import OpenClawProtocol
import UniformTypeIdentifiers
actor MacNodeBrowserProxy {
static let shared = MacNodeBrowserProxy()
struct Endpoint {
let baseURL: URL
let token: String?
let password: String?
}
private struct RequestParams: Decodable {
let method: String?
let path: String?
let query: [String: OpenClawProtocol.AnyCodable]?
let body: OpenClawProtocol.AnyCodable?
let timeoutMs: Int?
let profile: String?
}
private struct ProxyFilePayload {
let path: String
let base64: String
let mimeType: String?
func asJSON() -> [String: Any] {
var json: [String: Any] = [
"path": self.path,
"base64": self.base64,
]
if let mimeType = self.mimeType {
json["mimeType"] = mimeType
}
return json
}
}
private static let maxProxyFileBytes = 10 * 1024 * 1024
private let endpointProvider: @Sendable () -> Endpoint
private let performRequest: @Sendable (URLRequest) async throws -> (Data, URLResponse)
init(
session: URLSession = .shared,
endpointProvider: (@Sendable () -> Endpoint)? = nil,
performRequest: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil)
{
self.endpointProvider = endpointProvider ?? MacNodeBrowserProxy.defaultEndpoint
self.performRequest = performRequest ?? { request in
try await session.data(for: request)
}
}
func request(paramsJSON: String?) async throws -> String {
let params = try Self.decodeRequestParams(from: paramsJSON)
let request = try Self.makeRequest(params: params, endpoint: self.endpointProvider())
let (data, response) = try await self.performRequest(request)
let http = try Self.requireHTTPResponse(response)
guard (200..<300).contains(http.statusCode) else {
throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [
NSLocalizedDescriptionKey: Self.httpErrorMessage(statusCode: http.statusCode, data: data),
])
}
let result = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
let files = try Self.loadProxyFiles(from: result)
var payload: [String: Any] = ["result": result]
if !files.isEmpty {
payload["files"] = files.map { $0.asJSON() }
}
let payloadData = try JSONSerialization.data(withJSONObject: payload)
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
throw NSError(domain: "MacNodeBrowserProxy", code: 2, userInfo: [
NSLocalizedDescriptionKey: "browser proxy returned invalid UTF-8",
])
}
return payloadJSON
}
private static func defaultEndpoint() -> Endpoint {
let config = GatewayEndpointStore.localConfig()
let controlPort = GatewayEnvironment.gatewayPort() + 2
let baseURL = URL(string: "http://127.0.0.1:\(controlPort)")!
return Endpoint(baseURL: baseURL, token: config.token, password: config.password)
}
private static func decodeRequestParams(from raw: String?) throws -> RequestParams {
guard let raw else {
throw NSError(domain: "MacNodeBrowserProxy", code: 3, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}
return try JSONDecoder().decode(RequestParams.self, from: Data(raw.utf8))
}
private static func makeRequest(params: RequestParams, endpoint: Endpoint) throws -> URLRequest {
let method = (params.method ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let path = (params.path ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !path.isEmpty else {
throw NSError(domain: "MacNodeBrowserProxy", code: 1, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: path required",
])
}
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
guard var components = URLComponents(
url: endpoint.baseURL.appendingPathComponent(String(normalizedPath.dropFirst())),
resolvingAgainstBaseURL: false)
else {
throw NSError(domain: "MacNodeBrowserProxy", code: 4, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
])
}
var queryItems: [URLQueryItem] = []
if let query = params.query {
for key in query.keys.sorted() {
let value = query[key]?.value
guard value != nil, !(value is NSNull) else { continue }
queryItems.append(URLQueryItem(name: key, value: Self.stringValue(for: value)))
}
}
let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !profile.isEmpty && !queryItems.contains(where: { $0.name == "profile" }) {
queryItems.append(URLQueryItem(name: "profile", value: profile))
}
if !queryItems.isEmpty {
components.queryItems = queryItems
}
guard let url = components.url else {
throw NSError(domain: "MacNodeBrowserProxy", code: 5, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
])
}
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = params.timeoutMs.map { TimeInterval(max($0, 1)) / 1000 } ?? 5
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = endpoint.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else if let password = endpoint.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
}
if method != "GET", let body = params.body?.value {
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private static func requireHTTPResponse(_ response: URLResponse) throws -> HTTPURLResponse {
guard let http = response as? HTTPURLResponse else {
throw NSError(domain: "MacNodeBrowserProxy", code: 6, userInfo: [
NSLocalizedDescriptionKey: "browser proxy returned a non-HTTP response",
])
}
return http
}
private static func httpErrorMessage(statusCode: Int, data: Data) -> String {
if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
let error = object["error"] as? String,
!error.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return error
}
if let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
{
return text
}
return "HTTP \(statusCode)"
}
private static func stringValue(for value: Any?) -> String? {
guard let value else { return nil }
if let string = value as? String { return string }
if let bool = value as? Bool { return bool ? "true" : "false" }
if let number = value as? NSNumber { return number.stringValue }
return String(describing: value)
}
private static func loadProxyFiles(from result: Any) throws -> [ProxyFilePayload] {
let paths = self.collectProxyPaths(from: result)
return try paths.map(self.loadProxyFile)
}
private static func collectProxyPaths(from payload: Any) -> [String] {
guard let object = payload as? [String: Any] else { return [] }
var paths = Set<String>()
if let path = object["path"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
}
if let imagePath = object["imagePath"] as? String,
!imagePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
paths.insert(imagePath.trimmingCharacters(in: .whitespacesAndNewlines))
}
if let download = object["download"] as? [String: Any],
let path = download["path"] as? String,
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
}
return paths.sorted()
}
private static func loadProxyFile(path: String) throws -> ProxyFilePayload {
let url = URL(fileURLWithPath: path)
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else {
throw NSError(domain: "MacNodeBrowserProxy", code: 7, userInfo: [
NSLocalizedDescriptionKey: "browser proxy file not found: \(path)",
])
}
if let fileSize = values.fileSize, fileSize > Self.maxProxyFileBytes {
throw NSError(domain: "MacNodeBrowserProxy", code: 8, userInfo: [
NSLocalizedDescriptionKey: "browser proxy file exceeds 10MB: \(path)",
])
}
let data = try Data(contentsOf: url)
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
return ProxyFilePayload(path: path, base64: data.base64EncodedString(), mimeType: mimeType)
}
}

View File

@@ -32,6 +32,7 @@ final class MacNodeModeCoordinator {
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool?
var lastBrowserControlEnabled: Bool?
let defaults = UserDefaults.standard
while !Task.isCancelled {
@@ -48,6 +49,14 @@ final class MacNodeModeCoordinator {
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
let browserControlEnabled = OpenClawConfigFile.browserControlEnabled()
if lastBrowserControlEnabled == nil {
lastBrowserControlEnabled = browserControlEnabled
} else if lastBrowserControlEnabled != browserControlEnabled {
lastBrowserControlEnabled = browserControlEnabled
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
do {
let config = try await GatewayEndpointStore.shared.requireConfig()
@@ -108,6 +117,9 @@ final class MacNodeModeCoordinator {
private func currentCaps() -> [String] {
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
if OpenClawConfigFile.browserControlEnabled() {
caps.append(OpenClawCapability.browser.rawValue)
}
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
caps.append(OpenClawCapability.camera.rawValue)
}
@@ -142,6 +154,9 @@ final class MacNodeModeCoordinator {
]
let capsSet = Set(caps)
if capsSet.contains(OpenClawCapability.browser.rawValue) {
commands.append(OpenClawBrowserCommand.proxy.rawValue)
}
if capsSet.contains(OpenClawCapability.camera.rawValue) {
commands.append(OpenClawCameraCommand.list.rawValue)
commands.append(OpenClawCameraCommand.snap.rawValue)

View File

@@ -6,6 +6,7 @@ import OpenClawKit
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private let browserProxyRequest: @Sendable (String?) async throws -> String
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
private var eventSender: (@Sendable (String, String?) async -> Void)?
@@ -13,9 +14,13 @@ actor MacNodeRuntime {
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
},
browserProxyRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
try await MacNodeBrowserProxy.shared.request(paramsJSON: paramsJSON)
})
{
self.makeMainActorServices = makeMainActorServices
self.browserProxyRequest = browserProxyRequest
}
func updateMainSessionKey(_ sessionKey: String) {
@@ -50,6 +55,8 @@ actor MacNodeRuntime {
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue:
return try await self.handleA2UIInvoke(req)
case OpenClawBrowserCommand.proxy.rawValue:
return try await self.handleBrowserProxyInvoke(req)
case OpenClawCameraCommand.snap.rawValue,
OpenClawCameraCommand.clip.rawValue,
OpenClawCameraCommand.list.rawValue:
@@ -165,6 +172,19 @@ actor MacNodeRuntime {
}
}
private func handleBrowserProxyInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard OpenClawConfigFile.browserControlEnabled() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "BROWSER_DISABLED: enable Browser in Settings"))
}
let payloadJSON = try await self.browserProxyRequest(req.paramsJSON)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payloadJSON)
}
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard Self.cameraEnabled() else {
return BridgeInvokeResponse(

View File

@@ -9,24 +9,28 @@ struct PermissionsSettings: View {
let showOnboarding: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
SystemRunSettingsView()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
SystemRunSettingsView()
Text("Allow these so OpenClaw can notify and capture when needed.")
.padding(.top, 4)
Text("Allow these so OpenClaw can notify and capture when needed.")
.padding(.top, 4)
.fixedSize(horizontal: false, vertical: true)
PermissionStatusList(status: self.status, refresh: self.refresh)
.padding(.horizontal, 2)
.padding(.vertical, 6)
PermissionStatusList(status: self.status, refresh: self.refresh)
.padding(.horizontal, 2)
.padding(.vertical, 6)
LocationAccessSettings()
LocationAccessSettings()
Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
Spacer()
Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@@ -99,11 +103,16 @@ private struct LocationAccessSettings: View {
struct PermissionStatusList: View {
let status: [Capability: Bool]
let refresh: () async -> Void
@State private var pendingCapability: Capability?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(Capability.allCases, id: \.self) { cap in
PermissionRow(capability: cap, status: self.status[cap] ?? false) {
PermissionRow(
capability: cap,
status: self.status[cap] ?? false,
isPending: self.pendingCapability == cap)
{
Task { await self.handle(cap) }
}
}
@@ -122,20 +131,43 @@ struct PermissionStatusList: View {
@MainActor
private func handle(_ cap: Capability) async {
guard self.pendingCapability == nil else { return }
self.pendingCapability = cap
defer { self.pendingCapability = nil }
_ = await PermissionManager.ensure([cap], interactive: true)
await self.refreshStatusTransitions()
}
@MainActor
private func refreshStatusTransitions() async {
await self.refresh()
// TCC and notification settings can settle after the prompt closes or when the app regains focus.
for delay in [300_000_000, 900_000_000, 1_800_000_000] {
try? await Task.sleep(nanoseconds: UInt64(delay))
await self.refresh()
}
}
}
struct PermissionRow: View {
let capability: Capability
let status: Bool
let isPending: Bool
let compact: Bool
let action: () -> Void
init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) {
init(
capability: Capability,
status: Bool,
isPending: Bool = false,
compact: Bool = false,
action: @escaping () -> Void)
{
self.capability = capability
self.status = status
self.isPending = isPending
self.compact = compact
self.action = action
}
@@ -150,17 +182,49 @@ struct PermissionRow: View {
}
VStack(alignment: .leading, spacing: 2) {
Text(self.title).font(.body.weight(.semibold))
Text(self.subtitle).font(.caption).foregroundStyle(.secondary)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
if self.status {
Label("Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Button("Grant") { self.action() }
.buttonStyle(.bordered)
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
VStack(alignment: .trailing, spacing: 4) {
if self.status {
Label("Granted", systemImage: "checkmark.circle.fill")
.labelStyle(.iconOnly)
.foregroundStyle(.green)
.font(.title3)
.help("Granted")
} else if self.isPending {
ProgressView()
.controlSize(.small)
.frame(width: 78)
} else {
Button("Grant") { self.action() }
.buttonStyle(.bordered)
.controlSize(self.compact ? .small : .regular)
.frame(minWidth: self.compact ? 68 : 78, alignment: .trailing)
}
if self.status {
Text("Granted")
.font(.caption.weight(.medium))
.foregroundStyle(.green)
} else if self.isPending {
Text("Checking…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Request access")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(minWidth: self.compact ? 86 : 104, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, self.compact ? 4 : 6)
}

View File

@@ -1,3 +1,4 @@
import AppKit
import Observation
import SwiftUI
@@ -98,6 +99,10 @@ struct SettingsRootView: View {
.onChange(of: self.selectedTab) { _, newValue in
self.updatePermissionMonitoring(for: newValue)
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
guard self.selectedTab == .permissions else { return }
Task { await self.refreshPerms() }
}
.onDisappear { self.stopPermissionMonitoring() }
.task {
guard !self.isPreview else { return }