重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,236 @@
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 {
request.httpBody = try JSONSerialization.data(
withJSONObject: body.foundationValue,
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

@ -0,0 +1,115 @@
import CoreLocation
import Foundation
import OpenClawKit
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
enum Error: Swift.Error {
case timeout
case unavailable
}
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
var locationManager: CLLocationManager {
self.manager
}
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
get { self.locationContinuation }
set { self.locationContinuation = newValue }
}
override init() {
super.init()
self.configureLocationManager()
}
func currentLocation(
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
guard CLLocationManager.locationServicesEnabled() else {
throw Error.unavailable
}
return try await LocationCurrentRequest.resolve(
manager: self.manager,
desiredAccuracy: desiredAccuracy,
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs,
request: { try await self.requestLocationOnce() },
withTimeout: { timeoutMs, operation in
try await self.withTimeout(timeoutMs: timeoutMs) {
try await operation()
}
})
}
private func withTimeout<T: Sendable>(
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 {
return try await operation()
}
return try await withCheckedThrowingContinuation { continuation in
var didFinish = false
func finish(returning value: T) {
guard !didFinish else { return }
didFinish = true
continuation.resume(returning: value)
}
func finish(throwing error: Swift.Error) {
guard !didFinish else { return }
didFinish = true
continuation.resume(throwing: error)
}
let timeoutItem = DispatchWorkItem {
finish(throwing: Error.timeout)
}
DispatchQueue.main.asyncAfter(
deadline: .now() + .milliseconds(timeoutMs),
execute: timeoutItem)
Task { @MainActor in
do {
let value = try await operation()
timeoutItem.cancel()
finish(returning: value)
} catch {
timeoutItem.cancel()
finish(throwing: error)
}
}
}
}
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
if let latest = locations.last {
cont.resume(returning: latest)
} else {
cont.resume(throwing: Error.unavailable)
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
let errorCopy = error // Capture error for Sendable compliance
Task { @MainActor in
guard let cont = self.locationContinuation else { return }
self.locationContinuation = nil
cont.resume(throwing: errorCopy)
}
}
}

View file

@ -0,0 +1,188 @@
import Foundation
import OpenClawKit
import OSLog
@MainActor
final class MacNodeModeCoordinator {
static let shared = MacNodeModeCoordinator()
private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node")
private var task: Task<Void, Never>?
private let runtime = MacNodeRuntime()
private let session = GatewayNodeSession()
func start() {
guard self.task == nil else { return }
self.task = Task { [weak self] in
await self?.run()
}
}
func stop() {
self.task?.cancel()
self.task = nil
Task { await self.session.disconnect() }
}
func setPreferredGatewayStableID(_ stableID: String?) {
GatewayDiscoveryPreferences.setPreferredStableID(stableID)
Task { await self.session.disconnect() }
}
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool?
var lastBrowserControlEnabled: Bool?
let defaults = UserDefaults.standard
while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
try? await Task.sleep(nanoseconds: 1_000_000_000)
continue
}
let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false
if lastCameraEnabled == nil {
lastCameraEnabled = cameraEnabled
} else if lastCameraEnabled != cameraEnabled {
lastCameraEnabled = cameraEnabled
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()
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
let connectOptions = GatewayConnectOptions(
role: "node",
scopes: [],
caps: caps,
commands: commands,
permissions: permissions,
clientId: "openclaw-macos",
clientMode: "node",
clientDisplayName: InstanceIdentity.displayName)
let sessionBox = self.buildSessionBox(url: config.url)
try await self.session.connect(
url: config.url,
token: config.token,
bootstrapToken: nil,
password: config.password,
connectOptions: connectOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
self.logger.info("mac node connected to gateway")
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
await self.runtime.updateMainSessionKey(mainSessionKey)
await self.runtime.setEventSender { [weak self] event, payload in
guard let self else { return }
await self.session.sendEvent(event: event, payloadJSON: payload)
}
},
onDisconnected: { [weak self] reason in
guard let self else { return }
await self.runtime.setEventSender(nil)
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
},
onInvoke: { [weak self] req in
guard let self else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(code: .unavailable, message: "UNAVAILABLE: node not ready"))
}
return await self.runtime.handleInvoke(req)
})
retryDelay = 1_000_000_000
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
}
}
}
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)
}
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
if OpenClawLocationMode(rawValue: rawLocationMode) != .off {
caps.append(OpenClawCapability.location.rawValue)
}
return caps
}
private func currentPermissions() async -> [String: Bool] {
let statuses = await PermissionManager.status()
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
}
private func currentCommands(caps: [String]) -> [String] {
var commands: [String] = [
OpenClawCanvasCommand.present.rawValue,
OpenClawCanvasCommand.hide.rawValue,
OpenClawCanvasCommand.navigate.rawValue,
OpenClawCanvasCommand.evalJS.rawValue,
OpenClawCanvasCommand.snapshot.rawValue,
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
OpenClawCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.snapshot.rawValue,
MacNodeScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,
OpenClawSystemCommand.run.rawValue,
OpenClawSystemCommand.execApprovalsGet.rawValue,
OpenClawSystemCommand.execApprovalsSet.rawValue,
]
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)
commands.append(OpenClawCameraCommand.clip.rawValue)
}
if capsSet.contains(OpenClawCapability.location.rawValue) {
commands.append(OpenClawLocationCommand.get.rawValue)
}
return commands
}
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
guard url.scheme?.lowercased() == "wss" else { return nil }
let host = url.host ?? "gateway"
let port = url.port ?? 443
let stableID = "\(host):\(port)"
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
let params = GatewayTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: stored == nil,
storeKey: stableID)
let session = GatewayTLSPinningSession(params: params)
return WebSocketSessionBox(session: session)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,82 @@
import CoreLocation
import Foundation
import OpenClawKit
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
func locationAuthorizationStatus() -> CLAuthorizationStatus
func locationAccuracyAuthorization() -> CLAccuracyAuthorization
func currentLocation(
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
}
@MainActor
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
private let screenSnapshotter = ScreenSnapshotService()
private let screenRecorder = ScreenRecordService()
private let locationService = MacNodeLocationService()
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
try await self.screenSnapshotter.snapshot(
screenIndex: screenIndex,
maxWidth: maxWidth,
quality: quality,
format: format)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
try await self.screenRecorder.record(
screenIndex: screenIndex,
durationMs: durationMs,
fps: fps,
includeAudio: includeAudio,
outPath: outPath)
}
func locationAuthorizationStatus() -> CLAuthorizationStatus {
self.locationService.authorizationStatus()
}
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
self.locationService.accuracyAuthorization()
}
func currentLocation(
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
try await self.locationService.currentLocation(
desiredAccuracy: desiredAccuracy,
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs)
}
}

View file

@ -0,0 +1,22 @@
import Foundation
import OpenClawKit
enum MacNodeScreenCommand: String, Codable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
struct MacNodeScreenSnapshotParams: Codable, Equatable {
var screenIndex: Int?
var maxWidth: Int?
var quality: Double?
var format: OpenClawScreenSnapshotFormat?
}
struct MacNodeScreenRecordParams: Codable, Equatable {
var screenIndex: Int?
var durationMs: Int?
var fps: Double?
var format: String?
var includeAudio: Bool?
}