diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fdd30ea137..1a23e79c8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Codex: add Computer Use setup for Codex-mode agents, including `/codex computer-use status/install`, marketplace discovery, optional auto-install, and fail-closed MCP server checks before Codex-mode turns start. Fixes #72094. (#71842) Thanks @pash-openai. - Plugin SDK: expose shared channel route normalization, parser-driven target resolution, raw-target compact keys, parsed-target types, and route comparison helpers through `openclaw/plugin-sdk/channel-route`, switch native approval origin matching onto that route contract with optional delivery and match-only target normalization, and retire the internal channel-route shim behind dated compatibility aliases for legacy key/comparable-target helpers. Thanks @vincentkoc. +- Docs/Codex: document how Codex Computer Use, direct `cua-driver mcp`, and OpenClaw.app's PeekabooBridge fit together so desktop-control setup choices are clearer. Thanks @pash-openai and @trycua. - Matrix/streaming: stream tool-progress updates into live Matrix preview edits by default when preview streaming is active, with `streaming.preview.toolProgress: false` to keep answer previews while hiding interim tool lines. Thanks @gumadeiras. - Plugins/models: wire manifest `modelCatalog.aliases` and `modelCatalog.suppressions` into model-catalog planning and built-in model suppression, with OpenAI stale Spark suppression now declared in the plugin manifest before runtime fallback. Thanks @shakkernerd. - Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay. @@ -34,6 +35,7 @@ Docs: https://docs.openclaw.ai - Agents/ACPX: stop forwarding Codex ACP timeout config controls that Codex rejects while preserving OpenClaw's run-timeout watchdog for ACP subagents. Fixes #73052. Thanks @pfrederiksen and @richa65. - Memory Core: stream fallback vector search scoring with a bounded top-K result set so large indexes do not materialize every chunk embedding when sqlite-vec is unavailable. (#73069) Thanks @parkertoddbrooks. - Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys. +- macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy. - Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit. - Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev. - Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev. diff --git a/apps/macos-mlx-tts/Package.resolved b/apps/macos-mlx-tts/Package.resolved index d859b22ea65..f27c66e3ac8 100644 --- a/apps/macos-mlx-tts/Package.resolved +++ b/apps/macos-mlx-tts/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6b8aa02e612c43e309033a83de5f83b88d9c4267f124d1e062f66385dbbaa7ec", + "originHash" : "9d7e4eaf149efb6f69d1e17b04e81fc8bffe7cad13e0c21309b90a266b9f16a2", "pins" : [ { "identity" : "eventsource", @@ -15,8 +15,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Blaizzy/mlx-audio-swift", "state" : { - "revision" : "fcbd04daa1bfebe881932f630af2ba6ce9af3274", - "version" : "0.1.2" + "revision" : "fc4fe22dc41c053062e647a4e3db9142193670d2" } }, { @@ -33,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ml-explore/mlx-swift-lm.git", "state" : { - "revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff", - "version" : "2.31.3" + "revision" : "1c05248bb0899e2a7a4962b84d319cf12f4e12aa", + "version" : "3.31.3" } }, { @@ -69,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "476538ccb827f2dd18efc5de754cc87d77127a47", - "version" : "4.4.0" + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" } }, { @@ -96,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "cd6710454f25733900e133c6caf5188952763c36", - "version" : "2.98.0" + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" } }, { @@ -109,6 +108,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/apps/macos-mlx-tts/Package.swift b/apps/macos-mlx-tts/Package.swift index 96dffe3c40e..aa7703919d5 100644 --- a/apps/macos-mlx-tts/Package.swift +++ b/apps/macos-mlx-tts/Package.swift @@ -13,7 +13,7 @@ let package = Package( .executable(name: "openclaw-mlx-tts", targets: ["OpenClawMLXTTSHelper"]), ], dependencies: [ - .package(url: "https://github.com/Blaizzy/mlx-audio-swift", exact: "0.1.2"), + .package(url: "https://github.com/Blaizzy/mlx-audio-swift", revision: "fc4fe22dc41c053062e647a4e3db9142193670d2"), ], targets: [ .executableTarget( diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 29b35c9300c..ac41f58cd57 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7a8088405ec5e396c14d737c110ff5651ff25dabcd437a0fee92e57018c5360a", + "originHash" : "77c5e32a542e4c2ca3c7fff037abaa02066ea47cb2c2afc17927eda5a56aa5c0", "pins" : [ { "identity" : "axorcist", @@ -24,7 +24,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/ElevenLabsKit", "state" : { - "revision" : "7e3c948d8340abe3977014f3de020edf221e9269", + "revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d", "version" : "0.1.0" } }, @@ -43,7 +43,7 @@ "location" : "https://github.com/steipete/Peekaboo.git", "state" : { "branch" : "main", - "revision" : "8659b70d386d02f831e277386b3216023ccc707e" + "revision" : "461bc2e1ae4bfd6757eb003e528e8e26d55e06e9" } }, { diff --git a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift index 16cf8a39c39..049851193ba 100644 --- a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift +++ b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift @@ -2,11 +2,101 @@ import Foundation final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner { let watcher: SimpleFileWatcher + private let pollingWatcher: PollingDirectoryWatcher init(url: URL, onChange: @escaping () -> Void) { self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher( paths: [url.path], queueLabel: "ai.openclaw.canvaswatcher", onChange: onChange)) + self.pollingWatcher = PollingDirectoryWatcher( + url: url, + queueLabel: "ai.openclaw.canvaswatcher.poll", + onChange: onChange) + } + + func start() { + self.watcher.start() + self.pollingWatcher.start() + } + + func stop() { + self.watcher.stop() + self.pollingWatcher.stop() + } +} + +private final class PollingDirectoryWatcher: @unchecked Sendable { + private struct FileSignature: Equatable { + let modifiedAt: TimeInterval + let size: Int + } + + private let url: URL + private let queue: DispatchQueue + private let onChange: () -> Void + private var timer: DispatchSourceTimer? + private var lastSnapshot: [String: FileSignature] = [:] + + init(url: URL, queueLabel: String, onChange: @escaping () -> Void) { + self.url = url + self.queue = DispatchQueue(label: queueLabel) + self.onChange = onChange + } + + deinit { + self.stop() + } + + func start() { + self.queue.sync { + guard self.timer == nil else { return } + self.lastSnapshot = self.snapshot() + + let timer = DispatchSource.makeTimerSource(queue: self.queue) + timer.schedule(deadline: .now() + 0.15, repeating: 0.25) + timer.setEventHandler { [weak self] in + self?.poll() + } + self.timer = timer + timer.resume() + } + } + + func stop() { + self.queue.sync { + self.timer?.cancel() + self.timer = nil + self.lastSnapshot = [:] + } + } + + private func poll() { + let next = self.snapshot() + guard next != self.lastSnapshot else { return } + self.lastSnapshot = next + self.onChange() + } + + private func snapshot() -> [String: FileSignature] { + let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey] + guard let enumerator = FileManager.default.enumerator( + at: self.url, + includingPropertiesForKeys: keys, + options: [.skipsPackageDescendants]) + else { return [:] } + + var result: [String: FileSignature] = [:] + for case let fileURL as URL in enumerator { + guard let values = try? fileURL.resourceValues(forKeys: Set(keys)), + values.isRegularFile == true + else { continue } + + let relativePath = String(fileURL.path.dropFirst(self.url.path.count + 1)) + result[relativePath] = FileSignature( + modifiedAt: values.contentModificationDate?.timeIntervalSinceReferenceDate ?? 0, + size: values.fileSize ?? 0) + } + return result } } diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index 46c8c4bcadb..c8c28141eb4 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -227,6 +227,13 @@ enum ExecApprovalsStore { private static let defaultAskFallback: ExecSecurity = .deny private static let defaultAutoAllowSkills = false private static let secureStateDirPermissions = 0o700 + private static let fileLock = NSRecursiveLock() + + private static func withFileLock(_ body: () throws -> T) rethrows -> T { + self.fileLock.lock() + defer { self.fileLock.unlock() } + return try body() + } static func fileURL() -> URL { OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json") @@ -270,27 +277,31 @@ enum ExecApprovalsStore { } static func readSnapshot() -> ExecApprovalsSnapshot { - let url = self.fileURL() - guard FileManager().fileExists(atPath: url.path) else { + self.withFileLock { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsSnapshot( + path: url.path, + exists: false, + hash: self.hashRaw(nil), + file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) + } + let raw = try? String(contentsOf: url, encoding: .utf8) + let data = raw.flatMap { $0.data(using: .utf8) } + let decoded: ExecApprovalsFile = { + if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), + file.version == 1 + { + return file + } + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + }() return ExecApprovalsSnapshot( path: url.path, - exists: false, - hash: self.hashRaw(nil), - file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])) + exists: true, + hash: self.hashRaw(raw), + file: decoded) } - let raw = try? String(contentsOf: url, encoding: .utf8) - let data = raw.flatMap { $0.data(using: .utf8) } - let decoded: ExecApprovalsFile = { - if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 { - return file - } - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - }() - return ExecApprovalsSnapshot( - path: url.path, - exists: true, - hash: self.hashRaw(raw), - file: decoded) } static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile { @@ -310,62 +321,68 @@ enum ExecApprovalsStore { } static func loadFile() -> ExecApprovalsFile { - let url = self.fileURL() - guard FileManager().fileExists(atPath: url.path) else { - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) - } - do { - let data = try Data(contentsOf: url) - let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) - if decoded.version != 1 { + self.withFileLock { + let url = self.fileURL() + guard FileManager().fileExists(atPath: url.path) else { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + do { + let data = try Data(contentsOf: url) + let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data) + if decoded.version != 1 { + return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) + } + return decoded + } catch { + self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) } - return decoded - } catch { - self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)") - return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) } } static func saveFile(_ file: ExecApprovalsFile) { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(file) - let url = self.fileURL() - self.ensureSecureStateDirectory() - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - } catch { - self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") + self.withFileLock { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(file) + let url = self.fileURL() + self.ensureSecureStateDirectory() + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") + } } } static func ensureFile() -> ExecApprovalsFile { - self.ensureSecureStateDirectory() - let url = self.fileURL() - let existed = FileManager().fileExists(atPath: url.path) - let loaded = self.loadFile() - let loadedHash = self.hashFile(loaded) + self.withFileLock { + self.ensureSecureStateDirectory() + let url = self.fileURL() + let existed = FileManager().fileExists(atPath: url.path) + let loaded = self.loadFile() + let loadedHash = self.hashFile(loaded) - var file = self.normalizeIncoming(loaded) - if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } - let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if path.isEmpty { - file.socket?.path = self.socketPath() + var file = self.normalizeIncoming(loaded) + if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } + let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if path.isEmpty { + file.socket?.path = self.socketPath() + } + let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if token.isEmpty { + file.socket?.token = self.generateToken() + } + if file.agents == nil { file.agents = [:] } + if !existed || loadedHash != self.hashFile(file) { + self.saveFile(file) + } + return file } - let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if token.isEmpty { - file.socket?.token = self.generateToken() - } - if file.agents == nil { file.agents = [:] } - if !existed || loadedHash != self.hashFile(file) { - self.saveFile(file) - } - return file } static func resolve(agentId: String?) -> ExecApprovalsResolved { @@ -533,9 +550,11 @@ enum ExecApprovalsStore { } private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) { - var file = self.ensureFile() - mutate(&file) - self.saveFile(file) + self.withFileLock { + var file = self.ensureFile() + mutate(&file) + self.saveFile(file) + } } private static func ensureSecureStateDirectory() { diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 94c48e95de6..287df23e5fc 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -6,6 +6,19 @@ enum OpenClawConfigFile { private static let logger = Logger(subsystem: "ai.openclaw", category: "config") private static let configAuditFileName = "config-audit.jsonl" private static let configHealthFileName = "config-health.json" + private static let fileLock = NSRecursiveLock() + + private static func withFileLock(_ body: () throws -> T) rethrows -> T { + self.fileLock.lock() + defer { self.fileLock.unlock() } + return try body() + } + + #if DEBUG + static func withTestingFileLock(_ body: () throws -> T) rethrows -> T { + try self.withFileLock(body) + } + #endif static func url() -> URL { OpenClawPaths.configURL @@ -20,96 +33,100 @@ enum OpenClawConfigFile { } static func loadDict() -> [String: Any] { - let url = self.url() - guard FileManager().fileExists(atPath: url.path) else { return [:] } - do { - let data = try Data(contentsOf: url) - guard let root = self.parseConfigData(data) else { - self.observeConfigRead(data: data, root: nil, configURL: url, valid: false) - self.logger.warning("config JSON root invalid") + self.withFileLock { + let url = self.url() + guard FileManager().fileExists(atPath: url.path) else { return [:] } + do { + let data = try Data(contentsOf: url) + guard let root = self.parseConfigData(data) else { + self.observeConfigRead(data: data, root: nil, configURL: url, valid: false) + self.logger.warning("config JSON root invalid") + return [:] + } + self.observeConfigRead(data: data, root: root, configURL: url, valid: true) + return root + } catch { + self.logger.warning("config read failed: \(error.localizedDescription)") return [:] } - self.observeConfigRead(data: data, root: root, configURL: url, valid: true) - return root - } catch { - self.logger.warning("config read failed: \(error.localizedDescription)") - return [:] } } static func saveDict(_ dict: [String: Any]) { - // Nix mode disables config writes in production, but tests rely on saving temp configs. - if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } - let url = self.url() - let previousData = try? Data(contentsOf: url) - let previousRoot = previousData.flatMap { self.parseConfigData($0) } - let previousBytes = previousData?.count - let previousAttributes = try? FileManager().attributesOfItem(atPath: url.path) - let hadMetaBefore = self.hasMeta(previousRoot) - let gatewayModeBefore = self.gatewayMode(previousRoot) + self.withFileLock { + // Nix mode disables config writes in production, but tests rely on saving temp configs. + if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } + let url = self.url() + let previousData = try? Data(contentsOf: url) + let previousRoot = previousData.flatMap { self.parseConfigData($0) } + let previousBytes = previousData?.count + let previousAttributes = try? FileManager().attributesOfItem(atPath: url.path) + let hadMetaBefore = self.hasMeta(previousRoot) + let gatewayModeBefore = self.gatewayMode(previousRoot) - var output = dict - self.stampMeta(&output) + var output = dict + self.stampMeta(&output) - do { - let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) - try FileManager().createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - let nextBytes = data.count - let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path) - let gatewayModeAfter = self.gatewayMode(output) - let suspicious = self.configWriteSuspiciousReasons( - existsBefore: previousData != nil, - previousBytes: previousBytes, - nextBytes: nextBytes, - hadMetaBefore: hadMetaBefore, - gatewayModeBefore: gatewayModeBefore, - gatewayModeAfter: gatewayModeAfter) - if !suspicious.isEmpty { - self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") + do { + let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) + try FileManager().createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + let nextBytes = data.count + let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path) + let gatewayModeAfter = self.gatewayMode(output) + let suspicious = self.configWriteSuspiciousReasons( + existsBefore: previousData != nil, + previousBytes: previousBytes, + nextBytes: nextBytes, + hadMetaBefore: hadMetaBefore, + gatewayModeBefore: gatewayModeBefore, + gatewayModeAfter: gatewayModeAfter) + if !suspicious.isEmpty { + self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") + } + self.appendConfigWriteAudit([ + "result": "success", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": nextBytes, + "previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(), + "nextDev": self.fileSystemNumber(nextAttributes?[.systemNumber]) ?? NSNull(), + "previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(), + "nextIno": self.fileSystemNumber(nextAttributes?[.systemFileNumber]) ?? NSNull(), + "previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(), + "nextMode": self.posixMode(nextAttributes?[.posixPermissions]) ?? NSNull(), + "previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(), + "nextNlink": self.fileAttributeInt(nextAttributes?[.referenceCount]) ?? NSNull(), + "previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(), + "nextUid": self.fileAttributeInt(nextAttributes?[.ownerAccountID]) ?? NSNull(), + "previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(), + "nextGid": self.fileAttributeInt(nextAttributes?[.groupOwnerAccountID]) ?? NSNull(), + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": gatewayModeAfter ?? NSNull(), + "suspicious": suspicious, + ]) + self.observeConfigRead(data: data, root: output, configURL: url, valid: true) + } catch { + self.logger.error("config save failed: \(error.localizedDescription)") + self.appendConfigWriteAudit([ + "result": "failed", + "configPath": url.path, + "existsBefore": previousData != nil, + "previousBytes": previousBytes ?? NSNull(), + "nextBytes": NSNull(), + "hasMetaBefore": hadMetaBefore, + "hasMetaAfter": self.hasMeta(output), + "gatewayModeBefore": gatewayModeBefore ?? NSNull(), + "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), + "suspicious": [], + "error": error.localizedDescription, + ]) } - self.appendConfigWriteAudit([ - "result": "success", - "configPath": url.path, - "existsBefore": previousData != nil, - "previousBytes": previousBytes ?? NSNull(), - "nextBytes": nextBytes, - "previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(), - "nextDev": self.fileSystemNumber(nextAttributes?[.systemNumber]) ?? NSNull(), - "previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(), - "nextIno": self.fileSystemNumber(nextAttributes?[.systemFileNumber]) ?? NSNull(), - "previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(), - "nextMode": self.posixMode(nextAttributes?[.posixPermissions]) ?? NSNull(), - "previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(), - "nextNlink": self.fileAttributeInt(nextAttributes?[.referenceCount]) ?? NSNull(), - "previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(), - "nextUid": self.fileAttributeInt(nextAttributes?[.ownerAccountID]) ?? NSNull(), - "previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(), - "nextGid": self.fileAttributeInt(nextAttributes?[.groupOwnerAccountID]) ?? NSNull(), - "hasMetaBefore": hadMetaBefore, - "hasMetaAfter": self.hasMeta(output), - "gatewayModeBefore": gatewayModeBefore ?? NSNull(), - "gatewayModeAfter": gatewayModeAfter ?? NSNull(), - "suspicious": suspicious, - ]) - self.observeConfigRead(data: data, root: output, configURL: url, valid: true) - } catch { - self.logger.error("config save failed: \(error.localizedDescription)") - self.appendConfigWriteAudit([ - "result": "failed", - "configPath": url.path, - "existsBefore": previousData != nil, - "previousBytes": previousBytes ?? NSNull(), - "nextBytes": NSNull(), - "hasMetaBefore": hadMetaBefore, - "hasMetaAfter": self.hasMeta(output), - "gatewayModeBefore": gatewayModeBefore ?? NSNull(), - "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), - "suspicious": [], - "error": error.localizedDescription, - ]) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift index 479e9ca2e42..d96f3871809 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppStateRemoteConfigTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct AppStateRemoteConfigTests { @Test - func updatedRemoteGatewayConfigSetsTrimmedToken() { + func `updated remote gateway config sets trimmed token`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: [:], draft: .init( @@ -22,7 +22,7 @@ struct AppStateRemoteConfigTests { } @Test - func updatedRemoteGatewayConfigClearsTokenWhenBlank() { + func `updated remote gateway config clears token when blank`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: ["token": "old-token"], draft: .init( @@ -38,7 +38,7 @@ struct AppStateRemoteConfigTests { } @Test - func updatedRemoteGatewayConfigPinsLoopbackUrlForSshTransport() { + func `updated remote gateway config pins loopback url for ssh transport`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: ["url": "ws://gateway.example:18789"], draft: .init( @@ -56,7 +56,7 @@ struct AppStateRemoteConfigTests { } @Test - func updatedRemoteGatewayConfigPreservesCustomLoopbackTunnelPort() { + func `updated remote gateway config preserves custom loopback tunnel port`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: ["url": "ws://localhost.:29876"], draft: .init( @@ -72,7 +72,7 @@ struct AppStateRemoteConfigTests { } @Test - func updatedRemoteGatewayConfigPreservesCustomPortWhenExistingHostMatchesSshTarget() { + func `updated remote gateway config preserves custom port when existing host matches ssh target`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: ["url": "ws://gateway.example:19999"], draft: .init( @@ -88,7 +88,7 @@ struct AppStateRemoteConfigTests { } @Test - func updatedRemoteGatewayConfigDropsCustomPortWhenExistingHostDoesNotMatchSshTarget() { + func `updated remote gateway config drops custom port when existing host does not match ssh target`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: ["url": "ws://other-host.example:19999"], draft: .init( @@ -104,7 +104,7 @@ struct AppStateRemoteConfigTests { } @Test - func updatedRemoteGatewayConfigDoesNotPreservePortForHostnamePrefixCollision() { + func `updated remote gateway config does not preserve port for hostname prefix collision`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: ["url": "ws://example.attacker.tld:19999"], draft: .init( @@ -120,7 +120,7 @@ struct AppStateRemoteConfigTests { } @Test - func appStateInitDoesNotInferLoopbackHostIntoRemoteTarget() async { + func `app state init does not infer loopback host into remote target`() async { let configPath = TestIsolation.tempConfigPath() await TestIsolation.withIsolatedState( env: ["OPENCLAW_CONFIG_PATH": configPath], @@ -141,7 +141,7 @@ struct AppStateRemoteConfigTests { } @Test - func appStateInitPreservesExistingRemoteTargetWhenRemoteUrlIsLoopback() async { + func `app state init preserves existing remote target when remote url is loopback`() async { let configPath = TestIsolation.tempConfigPath() await TestIsolation.withIsolatedState( env: ["OPENCLAW_CONFIG_PATH": configPath], @@ -162,7 +162,7 @@ struct AppStateRemoteConfigTests { } @Test - func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() { + func `synced gateway root preserves object token across mode and transport changes when untouched`() { let initialRoot: [String: Any] = [ "gateway": [ "mode": "remote", @@ -187,7 +187,8 @@ struct AppStateRemoteConfigTests { remoteToken: "", remoteTokenDirty: false)) let sshRemote = (sshRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] - #expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret + #expect((sshRemote?["token"] as? [String: String])?["$secretRef"] == + "gateway-token") // pragma: allowlist secret let localRoot = AppState._testSyncedGatewayRoot( currentRoot: sshRoot, @@ -202,11 +203,12 @@ struct AppStateRemoteConfigTests { let localGateway = localRoot["gateway"] as? [String: Any] let localRemote = localGateway?["remote"] as? [String: Any] #expect(localGateway?["mode"] as? String == "local") - #expect((localRemote?["token"] as? [String: String])?["$secretRef"] == "gateway-token") // pragma: allowlist secret + #expect((localRemote?["token"] as? [String: String])?["$secretRef"] == + "gateway-token") // pragma: allowlist secret } @Test - func updatedRemoteGatewayConfigReplacesObjectTokenWhenUserEntersPlaintext() { + func `updated remote gateway config replaces object token when user enters plaintext`() { let remote = AppState._testUpdatedRemoteGatewayConfig( current: [ "token": [ @@ -226,7 +228,7 @@ struct AppStateRemoteConfigTests { } @Test - func updatedRemoteGatewayConfigClearsObjectTokenOnlyAfterExplicitEdit() { + func `updated remote gateway config clears object token only after explicit edit`() { let current: [String: Any] = [ "token": [ "$secretRef": "gateway-token", // pragma: allowlist secret diff --git a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift index 085e83ee9c6..c81e328bba4 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift @@ -195,7 +195,7 @@ struct ChannelsSettingsSmokeTests { #expect( whatsappLoginWaitRequestTimeoutMs( startedAt: startedAt, - timeoutMs: 1_000, + timeoutMs: 1000, didRunFinalWait: &didRunFinalWait, now: Date(timeInterval: 0.25, since: startedAt)) == 750) #expect(didRunFinalWait == false) @@ -203,7 +203,7 @@ struct ChannelsSettingsSmokeTests { #expect( whatsappLoginWaitRequestTimeoutMs( startedAt: startedAt, - timeoutMs: 1_000, + timeoutMs: 1000, didRunFinalWait: &didRunFinalWait, now: Date(timeInterval: 1.25, since: startedAt)) == 1) #expect(didRunFinalWait == true) @@ -211,7 +211,7 @@ struct ChannelsSettingsSmokeTests { #expect( whatsappLoginWaitRequestTimeoutMs( startedAt: startedAt, - timeoutMs: 1_000, + timeoutMs: 1000, didRunFinalWait: &didRunFinalWait, now: Date(timeInterval: 1.5, since: startedAt)) == nil) } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index 0f65147220b..b2742234590 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -199,7 +199,11 @@ struct ExecAllowlistTests { } @Test func `resolve for allowlist fails closed on chained line-continued command substitution`() { - let command = ["/bin/sh", "-lc", "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)"] + let command = [ + "/bin/sh", + "-lc", + "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)", + ] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, rawCommand: "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)", diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift index 03b17b42ab2..d00754e5f95 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift @@ -55,25 +55,25 @@ struct ExecApprovalsGatewayPrompterTests { // MARK: - shouldAsk - @Test func askAlwaysPromptsRegardlessOfSecurity() { + @Test func `ask always prompts regardless of security`() { #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .always)) #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .always)) #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .always)) } - @Test func askOnMissPromptsOnlyForAllowlist() { + @Test func `ask on miss prompts only for allowlist`() { #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .onMiss)) #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .onMiss)) #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .onMiss)) } - @Test func askOffNeverPrompts() { + @Test func `ask off never prompts`() { #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .off)) #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .off)) #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .off)) } - @Test func fallbackAllowlistAllowsMatchingResolvedPath() { + @Test func `fallback allowlist allows matching resolved path`() { let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( command: "git status", resolvedPath: "/usr/bin/git", @@ -82,7 +82,7 @@ struct ExecApprovalsGatewayPrompterTests { #expect(decision == .allowOnce) } - @Test func fallbackAllowlistDeniesAllowlistMiss() { + @Test func `fallback allowlist denies allowlist miss`() { let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( command: "git status", resolvedPath: "/usr/bin/git", @@ -91,7 +91,7 @@ struct ExecApprovalsGatewayPrompterTests { #expect(decision == .deny) } - @Test func fallbackFullAllowsWhenPromptCannotBeShown() { + @Test func `fallback full allows when prompt cannot be shown`() { let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( command: "git status", resolvedPath: "/usr/bin/git", diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSkillBinTrustTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSkillBinTrustTests.swift index 779b59a3499..febbb634561 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecSkillBinTrustTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSkillBinTrustTests.swift @@ -84,7 +84,7 @@ struct ExecSkillBinTrustTests { requirements: SkillRequirements(bins: bins, env: [], config: []), missing: SkillMissing(bins: [], env: [], config: []), configChecks: [], - install: []) + install: []), ]) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index 6726fe1986a..e750bb7e52b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -33,7 +33,8 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { self.respondedRequestIds.insert(request.id) if request.method == "connect" { return .string(""" - {"type":"res","id":"\(request.id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"policy":{}}} + {"type":"res","id":"\(request + .id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"auth":{},"policy":{}}} """) } return .string(""" @@ -50,14 +51,13 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable { private func latestUnrespondedRequest() -> (id: String, method: String)? { for message in self.sentMessages.reversed() { - let data: Data? - switch message { - case .string(let text): - data = Data(text.utf8) - case .data(let raw): - data = raw + let data: Data? = switch message { + case let .string(text): + Data(text.utf8) + case let .data(raw): + raw @unknown default: - data = nil + nil } guard let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -81,6 +81,23 @@ private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendab } } +private final class WebSocketMessageRecorder: @unchecked Sendable { + private let lock = NSLock() + private var messages: [URLSessionWebSocketTask.Message] = [] + + func append(_ message: URLSessionWebSocketTask.Message) { + self.lock.lock() + defer { self.lock.unlock() } + self.messages.append(message) + } + + func snapshot() -> [URLSessionWebSocketTask.Message] { + self.lock.lock() + defer { self.lock.unlock() } + return self.messages + } +} + private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSession) { let session = FakeWebSocketSession() let connection = GatewayConnection( @@ -95,6 +112,7 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes @Test func `status fails when process missing`() async { let (connection, _) = makeTestGatewayConnection() let result = await connection.status() + await connection.shutdown() #expect(result.ok == false) #expect(result.error != nil) } @@ -111,9 +129,22 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes } @Test func `send agent keeps empty voice wake trigger field`() async throws { - let (connection, session) = makeTestGatewayConnection() - session.task.autoRespond = true - _ = await connection.sendAgent(GatewayAgentInvocation( + let recorder = WebSocketMessageRecorder() + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + recorder.append(message) + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id))) + }) + }) + let connection = GatewayConnection( + configProvider: { + (url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil) + }, + sessionBox: WebSocketSessionBox(session: session)) + let result = await connection.sendAgent(GatewayAgentInvocation( message: "test", sessionKey: "main", thinking: nil, @@ -123,19 +154,21 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes timeoutSeconds: nil, idempotencyKey: "idem-1", voiceWakeTrigger: " ")) + await connection.shutdown() + #expect(result.ok == true) - guard let lastMessage = session.task.sentMessages.last else { - Issue.record("expected websocket send payload") + guard let agentMessage = recorder.snapshot().reversed().first(where: { message in + guard let data = Self.messageData(message), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return false } + return json["method"] as? String == "agent" + }) else { + Issue.record("expected agent websocket send payload") return } - let payloadData: Data - switch lastMessage { - case .string(let text): - payloadData = Data(text.utf8) - case .data(let data): - payloadData = data - @unknown default: - Issue.record("unexpected websocket message type") + + guard let payloadData = Self.messageData(agentMessage) else { + Issue.record("unexpected agent websocket message type") return } @@ -143,4 +176,15 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes let params = json?["params"] as? [String: Any] #expect(params?["voiceWakeTrigger"] as? String == "") } + + private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? { + switch message { + case let .string(text): + Data(text.utf8) + case let .data(data): + data + @unknown default: + nil + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 418780c1a70..e4f14ea36d8 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -61,7 +61,7 @@ struct GatewayEndpointStoreTests { #expect(token == nil) } - @Test func resolveGatewayTokenUsesRemoteConfigToken() { + @Test func `resolve gateway token uses remote config token`() { let token = GatewayEndpointStore._testResolveGatewayToken( isRemote: true, root: [ @@ -76,7 +76,7 @@ struct GatewayEndpointStoreTests { #expect(token == "remote-token") } - @Test func resolveGatewayPasswordFallsBackToLaunchd() { + @Test func `resolve gateway password falls back to launchd`() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], token: nil, @@ -214,7 +214,7 @@ struct GatewayEndpointStoreTests { launchdSnapshot: snapshot, tailscaleIP: "100.64.1.8") - #expect(config.url.absoluteString == "wss://100.64.1.8:18789") + #expect(config.url.absoluteString == "wss://100.64.1.8:\(GatewayEnvironment.gatewayPort())") #expect(config.token == "launchd-token") #expect(config.password == "launchd-pass") } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index da6c60372c9..c55d8ed46c8 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -37,90 +37,93 @@ struct GatewayProcessManagerTests { } @Test func `attaches to existing gateway without spawning launchd`() async throws { - let healthData = Data( - """ - { - "ok": true, - "ts": 1, - "durationMs": 0, - "channels": { - "telegram": { - "configured": true, - "linked": true, - "authAgeMs": 60000 + let port = 19097 + try await TestIsolation.withEnvValues(["OPENCLAW_GATEWAY_PORT": "\(port)"]) { + let healthData = Data( + """ + { + "ok": true, + "ts": 1, + "durationMs": 0, + "channels": { + "telegram": { + "configured": true, + "linked": true, + "authAgeMs": 60000 + } + }, + "channelOrder": ["telegram"], + "channelLabels": { + "telegram": "Telegram" + }, + "heartbeatSeconds": 30, + "sessions": { + "path": "/tmp/sessions", + "count": 1, + "recent": [] + } } - }, - "channelOrder": ["telegram"], - "channelLabels": { - "telegram": "Telegram" - }, - "heartbeatSeconds": 30, - "sessions": { - "path": "/tmp/sessions", - "count": 1, - "recent": [] - } + """.utf8) + let session = GatewayTestWebSocketSession( + taskFactory: { + GatewayTestWebSocketTask( + sendHook: { task, message, sendIndex in + guard sendIndex > 0 else { return } + guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return } + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": \(String(decoding: healthData, as: UTF8.self)) + } + """ + task.emitReceiveSuccess(.data(Data(json.utf8))) + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let connection = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let descriptor = PortGuardian.Descriptor( + pid: 4242, + command: "openclaw-gateway", + executablePath: "/tmp/openclaw-gateway") + + let manager = GatewayProcessManager.shared + await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port) + manager.setTestingConnection(connection) + manager.setTestingSkipControlChannelRefresh(true) + manager.setTestingLastFailureReason("stale") + + @MainActor + func cleanup() async { + manager.setTestingConnection(nil) + manager.setTestingSkipControlChannelRefresh(false) + manager.setTestingDesiredActive(false) + manager.setTestingLastFailureReason(nil) + await PortGuardian.shared.setTestingDescriptor(nil, forPort: port) } - """.utf8) - let session = GatewayTestWebSocketSession( - taskFactory: { - GatewayTestWebSocketTask( - sendHook: { task, message, sendIndex in - guard sendIndex > 0 else { return } - guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return } - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": \(String(decoding: healthData, as: UTF8.self)) - } - """ - task.emitReceiveSuccess(.data(Data(json.utf8))) - }) - }) - let url = try #require(URL(string: "ws://example.invalid")) - let connection = GatewayConnection( - configProvider: { (url: url, token: nil, password: nil) }, - sessionBox: WebSocketSessionBox(session: session)) - let port = GatewayEnvironment.gatewayPort() - let descriptor = PortGuardian.Descriptor( - pid: 4242, - command: "openclaw-gateway", - executablePath: "/tmp/openclaw-gateway") - let manager = GatewayProcessManager.shared - await PortGuardian.shared.setTestingDescriptor(descriptor, forPort: port) - manager.setTestingConnection(connection) - manager.setTestingSkipControlChannelRefresh(true) - manager.setTestingLastFailureReason("stale") - - func cleanup() async { - await PortGuardian.shared.setTestingDescriptor(nil, forPort: port) - manager.setTestingConnection(nil) - manager.setTestingSkipControlChannelRefresh(false) - manager.setTestingDesiredActive(false) - manager.setTestingLastFailureReason(nil) - } - - do { - let attached = await manager._testAttachExistingGatewayIfAvailable() - #expect(attached) - #expect(manager.lastFailureReason == nil) - guard case let .attachedExisting(statusDetails) = manager.status else { - Issue.record("expected attachedExisting status") + do { + let attached = await manager._testAttachExistingGatewayIfAvailable() + #expect(attached) + #expect(manager.lastFailureReason == nil) + guard case let .attachedExisting(statusDetails) = manager.status else { + Issue.record("expected attachedExisting status") + await cleanup() + return + } + let details = try #require(statusDetails) + #expect(details.contains("port \(port)")) + #expect(details.contains("Telegram linked")) + #expect(details.contains("auth 1m")) + #expect(details.contains("pid 4242 openclaw-gateway @ /tmp/openclaw-gateway")) await cleanup() - return + } catch { + await cleanup() + throw error } - let details = try #require(statusDetails) - #expect(details.contains("port \(port)")) - #expect(details.contains("Telegram linked")) - #expect(details.contains("auth 1m")) - #expect(details.contains("pid 4242 openclaw-gateway @ /tmp/openclaw-gateway")) - await cleanup() - } catch { - await cleanup() - throw error } } } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift index f321fed5679..66503dbfe02 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -60,14 +60,13 @@ enum GatewayWebSocketTestSupport { canRetryWithDeviceToken: Bool = false, recommendedNextStep: String? = nil) -> Data { - let recommendedNextStepJson: String - if let recommendedNextStep { - recommendedNextStepJson = """ + let recommendedNextStepJson = if let recommendedNextStep { + """ , "recommendedNextStep": "\(recommendedNextStep)" """ } else { - recommendedNextStepJson = "" + "" } let json = """ { diff --git a/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift index c9a17d57577..bb39116c231 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LaunchAgentManagerTests.swift @@ -7,8 +7,7 @@ struct LaunchAgentManagerTests { let plist = LaunchAgentManager.plistContents(bundlePath: "/Applications/OpenClaw.app") let data = try #require(plist.data(using: .utf8)) let object = try #require( - PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] - ) + PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]) #expect(object["RunAtLoad"] as? Bool == true) #expect(object["KeepAlive"] == nil) diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift index 53643958ded..af4293513b0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift @@ -140,20 +140,22 @@ struct LowCoverageHelperTests { } @Test func `port guardian remote mode does not kill docker`() { + let port = GatewayEnvironment.gatewayPort() + #expect(PortGuardian._testIsExpected( command: "com.docker.backend", fullCommand: "com.docker.backend", - port: 18789, mode: .remote) == true) + port: port, mode: .remote) == true) #expect(PortGuardian._testIsExpected( command: "ssh", - fullCommand: "ssh -L 18789:localhost:18789 user@host", - port: 18789, mode: .remote) == true) + fullCommand: "ssh -L \(port):localhost:\(port) user@host", + port: port, mode: .remote) == true) #expect(PortGuardian._testIsExpected( command: "podman", fullCommand: "podman", - port: 18789, mode: .remote) == true) + port: port, mode: .remote) == true) } @Test func `port guardian local mode still rejects unexpected`() { @@ -181,14 +183,20 @@ struct LowCoverageHelperTests { @Test func `port guardian remote mode report accepts any listener`() { let dockerReport = PortGuardian._testBuildReport( port: 18789, mode: .remote, - listeners: [(pid: 99, command: "com.docker.backend", - fullCommand: "com.docker.backend", user: "me")]) + listeners: [( + pid: 99, + command: "com.docker.backend", + fullCommand: "com.docker.backend", + user: "me")]) #expect(dockerReport.offenders.isEmpty) let localDockerReport = PortGuardian._testBuildReport( port: 18789, mode: .local, - listeners: [(pid: 99, command: "com.docker.backend", - fullCommand: "com.docker.backend", user: "me")]) + listeners: [( + pid: 99, + command: "com.docker.backend", + fullCommand: "com.docker.backend", + user: "me")]) #expect(!localDockerReport.offenders.isEmpty) } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift index 65a16dc0bac..0b3a6c0d865 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift @@ -39,8 +39,8 @@ struct MacNodeBrowserProxyTests { #expect(tabs[0]["id"] as? String == "tab-1") } - // Regression test: nested POST bodies must serialize without __SwiftValue crashes. - @Test func postRequestSerializesNestedBodyWithoutCrash() async throws { + /// Regression test: nested POST bodies must serialize without __SwiftValue crashes. + @Test func `post request serializes nested body without crash`() async throws { actor BodyCapture { private var body: Data? @@ -84,7 +84,7 @@ struct MacNodeBrowserProxyTests { #expect(arr.count == 2) } - @Test func requestReportsActionableUnavailableWhenControlServiceIsMissing() async throws { + @Test func `request reports actionable unavailable when control service is missing`() async throws { let proxy = MacNodeBrowserProxy( endpointProvider: { MacNodeBrowserProxy.Endpoint( diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index fb95ce8f977..4156c191dbe 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -4,7 +4,7 @@ import Testing @testable import OpenClaw struct MacNodeModeCoordinatorTests { - @Test func remoteModeDoesNotAdvertiseBrowserProxy() { + @Test func `remote mode does not advertise browser proxy`() { let caps = MacNodeModeCoordinator.resolvedCaps( browserControlEnabled: true, cameraEnabled: false, @@ -18,7 +18,7 @@ struct MacNodeModeCoordinatorTests { #expect(commands.contains(OpenClawSystemCommand.notify.rawValue)) } - @Test func localModeAdvertisesBrowserProxyWhenEnabled() { + @Test func `local mode advertises browser proxy when enabled`() { let caps = MacNodeModeCoordinator.resolvedCaps( browserControlEnabled: true, cameraEnabled: false, diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift index b1d01b9650e..0d6467d786b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) @MainActor struct MenuSessionsInjectorTests { - @Test func anchorsDynamicRowsBelowControlsAndActions() throws { + @Test func `anchors dynamic rows below controls and actions`() throws { let injector = MenuSessionsInjector() let menu = NSMenu() @@ -24,7 +24,7 @@ struct MenuSessionsInjectorTests { #expect(injector.testingFindNodesInsertIndex(in: menu) == footerSeparatorIndex) } - @Test func injectsDisconnectedMessage() { + @Test func `injects disconnected message`() { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(false) injector.setTestingSnapshot(nil, errorText: nil) @@ -38,7 +38,7 @@ struct MenuSessionsInjectorTests { #expect(menu.items.contains { $0.tag == 9_415_557 }) } - @Test func injectsSessionRows() throws { + @Test func `injects session rows`() throws { let injector = MenuSessionsInjector() injector.setTestingControlChannelConnected(true) diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift index 00f3e704708..6bb87b16061 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift @@ -121,9 +121,12 @@ struct OnboardingRemoteAuthPromptTests { let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none) #expect(pairedDevice.title == "Connected via paired device") - #expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.") + #expect(pairedDevice + .detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.") #expect(bootstrap.title == "Connected with setup code") - #expect(bootstrap.detail == "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.") + #expect(bootstrap + .detail == + "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.") #expect(sharedToken.title == "Connected with gateway token") #expect(sharedToken.detail == nil) #expect(noAuth.title == "Remote gateway ready") diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index ad559be0957..14ab472558d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -176,57 +176,59 @@ struct OpenClawConfigFileTests { "OPENCLAW_STATE_DIR": stateDir.path, "OPENCLAW_CONFIG_PATH": configPath.path, ]) { - OpenClawConfigFile.saveDict([ - "update": ["channel": "beta"], - "browser": ["enabled": true], - "gateway": ["mode": "local"], - "channels": [ - "discord": [ - "enabled": true, - "dmPolicy": "pairing", + try OpenClawConfigFile.withTestingFileLock { + OpenClawConfigFile.saveDict([ + "update": ["channel": "beta"], + "browser": ["enabled": true], + "gateway": ["mode": "local"], + "channels": [ + "discord": [ + "enabled": true, + "dmPolicy": "pairing", + ], ], - ], - ]) - _ = OpenClawConfigFile.loadDict() + ]) + _ = OpenClawConfigFile.loadDict() - let clobbered = """ - { - "update": { - "channel": "beta" - } - } - """ - try clobbered.write(to: configPath, atomically: true, encoding: .utf8) + let clobbered = """ + { + "update": { + "channel": "beta" + } + } + """ + try clobbered.write(to: configPath, atomically: true, encoding: .utf8) - let loaded = OpenClawConfigFile.loadDict() - #expect((loaded["gateway"] as? [String: Any]) == nil) + let loaded = OpenClawConfigFile.loadDict() + #expect((loaded["gateway"] as? [String: Any]) == nil) - let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) - let lines = rawAudit - .split(whereSeparator: \.isNewline) - .map(String.init) - let observeLine = lines.reversed().first { $0.contains("\"event\":\"config.observe\"") } - #expect(observeLine != nil) - guard let observeLine else { - Issue.record("Missing config.observe audit line") - return - } - let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any] - #expect(auditRoot?["source"] as? String == "macos-openclaw-config-file") - #expect(auditRoot?["configPath"] as? String == configPath.path) - #expect(auditRoot?["mode"] is NSNumber) - #expect(auditRoot?["ino"] as? String != nil) - #expect(auditRoot?["lastKnownGoodMode"] is NSNumber) - #expect(auditRoot?["backupMode"] is NSNull) - let suspicious = auditRoot?["suspicious"] as? [String] ?? [] - #expect(suspicious.contains("gateway-mode-missing-vs-last-good")) - #expect(suspicious.contains("update-channel-only-root")) + let rawAudit = try String(contentsOf: auditPath, encoding: .utf8) + let lines = rawAudit + .split(whereSeparator: \.isNewline) + .map(String.init) + let observeLine = lines.reversed().first { $0.contains("\"event\":\"config.observe\"") } + #expect(observeLine != nil) + guard let observeLine else { + Issue.record("Missing config.observe audit line") + return + } + let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any] + #expect(auditRoot?["source"] as? String == "macos-openclaw-config-file") + #expect(auditRoot?["configPath"] as? String == configPath.path) + #expect(auditRoot?["mode"] is NSNumber) + #expect(auditRoot?["ino"] as? String != nil) + #expect(auditRoot?["lastKnownGoodMode"] is NSNumber) + #expect(auditRoot?["backupMode"] is NSNull) + let suspicious = auditRoot?["suspicious"] as? [String] ?? [] + #expect(suspicious.contains("gateway-mode-missing-vs-last-good")) + #expect(suspicious.contains("update-channel-only-root")) - let clobberedPath = auditRoot?["clobberedPath"] as? String - #expect(clobberedPath != nil) - if let clobberedPath { - let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8) - #expect(preserved == clobbered) + let clobberedPath = auditRoot?["clobberedPath"] as? String + #expect(clobberedPath != nil) + if let clobberedPath { + let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8) + #expect(preserved == clobbered) + } } } } diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift index 782dbd77212..0aa94789850 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -84,8 +84,7 @@ struct RuntimeLocatorTests { kind: .node, raw: "garbage", path: "/usr/local/bin/node", - searchPaths: ["/usr/local/bin"], - )) + searchPaths: ["/usr/local/bin"])) #expect(parseMsg.contains("Node >=22.16.0")) } diff --git a/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift b/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift index d2b5b007923..736413fbfc0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TalkAudioPlayerTests.swift @@ -8,7 +8,7 @@ import Testing let wav = makeWav16Mono(sampleRate: 8000, samples: 80) defer { _ = TalkAudioPlayer.shared.stop() } - _ = try await withTimeout(seconds: 4.0) { + _ = try await withTimeout(seconds: 10.0) { await TalkAudioPlayer.shared.play(data: wav) } @@ -27,7 +27,7 @@ import Testing await Task.yield() _ = await TalkAudioPlayer.shared.play(data: wav) - _ = try await withTimeout(seconds: 4.0) { + _ = try await withTimeout(seconds: 10.0) { await first.value } #expect(true) diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift index 1d47aa01bc7..a4a9b74772e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift @@ -109,6 +109,7 @@ struct VoiceWakeRuntimeTests { trimWake: VoiceWakeRuntime._testTrimmedAfterTrigger) #expect(match == nil) } + @Test func `trims after chinese trigger keeps post speech`() { let triggers = ["小爪", "openclaw"] let text = "嘿 小爪 帮我打开设置" diff --git a/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift b/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift index 7b9a7844821..72cc5c77eee 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WideAreaGatewayDiscoveryTests.swift @@ -16,7 +16,7 @@ private final class NameserverQueryLog: @unchecked Sendable { func count(matching nameserver: String) -> Int { self.lock.lock() defer { self.lock.unlock() } - return self.nameservers.filter { $0 == nameserver }.count + return self.nameservers.count(where: { $0 == nameserver }) } } diff --git a/docs/platforms/mac/peekaboo.md b/docs/platforms/mac/peekaboo.md index 77a0d87c03a..c156b3c1097 100644 --- a/docs/platforms/mac/peekaboo.md +++ b/docs/platforms/mac/peekaboo.md @@ -4,6 +4,7 @@ read_when: - Hosting PeekabooBridge in OpenClaw.app - Integrating Peekaboo via Swift Package Manager - Changing PeekabooBridge protocol/paths + - Deciding between PeekabooBridge, Codex Computer Use, and cua-driver MCP title: "Peekaboo bridge" --- @@ -17,6 +18,29 @@ macOS app’s TCC permissions. - **Client**: use the `peekaboo` CLI (no separate `openclaw ui ...` surface). - **UI**: visual overlays stay in Peekaboo.app; OpenClaw is a thin broker host. +## Relationship to Computer Use + +OpenClaw has three desktop-control paths, and they intentionally stay separate: + +- **PeekabooBridge host**: OpenClaw.app can host the local PeekabooBridge socket. + The `peekaboo` CLI remains the client and uses OpenClaw.app's macOS + permissions for Peekaboo automation primitives such as screenshots, clicks, + menus, dialogs, Dock actions, and window management. +- **Codex Computer Use**: the bundled `codex` plugin prepares Codex app-server, + verifies that Codex's `computer-use` MCP server is available, and then lets + Codex own native desktop-control tool calls during Codex-mode turns. OpenClaw + does not proxy those actions through PeekabooBridge. +- **Direct `cua-driver` MCP**: OpenClaw can register TryCua's upstream + `cua-driver mcp` server as a normal MCP server. That gives agents the CUA + driver's own schemas and pid/window/element-index workflow without routing + through the Codex marketplace or the PeekabooBridge socket. + +Use Peekaboo when you want the broad macOS automation surface and OpenClaw.app's +permission-aware bridge host. Use Codex Computer Use when a Codex-mode agent +should rely on Codex's native computer-use plugin. Use direct `cua-driver mcp` +when you want the CUA driver exposed to any OpenClaw-managed runtime as a normal +MCP server. + ## Enable the bridge In the macOS app: diff --git a/docs/plugins/codex-computer-use.md b/docs/plugins/codex-computer-use.md index 24df3bc148c..7a0c35399d6 100644 --- a/docs/plugins/codex-computer-use.md +++ b/docs/plugins/codex-computer-use.md @@ -3,6 +3,8 @@ summary: "Set up Codex Computer Use for Codex-mode OpenClaw agents" title: "Codex Computer Use" read_when: - You want Codex-mode OpenClaw agents to use Codex Computer Use + - You are deciding between Codex Computer Use, PeekabooBridge, and direct cua-driver MCP + - You are deciding between Codex Computer Use and a direct cua-driver MCP setup - You are configuring computerUse for the bundled Codex plugin - You are troubleshooting /codex computer-use status or install --- @@ -17,6 +19,49 @@ then lets Codex own the native MCP tool calls during Codex-mode turns. Use this page when OpenClaw is already using the native Codex harness. For the runtime setup itself, see [Codex harness](/plugins/codex-harness). +## OpenClaw.app and Peekaboo + +OpenClaw.app's Peekaboo integration is separate from Codex Computer Use. The +macOS app can host a PeekabooBridge socket so the `peekaboo` CLI can reuse the +app's local Accessibility and Screen Recording grants for Peekaboo's own +automation tools. That bridge does not install or proxy Codex Computer Use, and +Codex Computer Use does not call through the PeekabooBridge socket. + +Use [Peekaboo bridge](/platforms/mac/peekaboo) when you want OpenClaw.app to be +a permission-aware host for Peekaboo CLI automation. Use this page when a +Codex-mode OpenClaw agent should have Codex's native `computer-use` MCP plugin +available before the turn starts. + +## Direct cua-driver MCP + +Codex Computer Use is not the only way to expose desktop control. If you want +OpenClaw-managed runtimes to call TryCua's driver directly, use the upstream +`cua-driver mcp` server through OpenClaw's MCP registry instead of the +Codex-specific marketplace flow. + +After installing `cua-driver`, either ask it for the OpenClaw command: + +```bash +cua-driver mcp-config --client openclaw +``` + +or register the stdio server yourself: + +```bash +openclaw mcp set cua-driver '{"command":"cua-driver","args":["mcp"]}' +``` + +That path keeps the upstream MCP tool surface intact, including the driver +schemas and structured MCP responses. Use it when you want the CUA driver +available as a normal OpenClaw MCP server. Use the Codex Computer Use setup on +this page when Codex app-server should own plugin installation, MCP reloads, +and native tool calls inside Codex-mode turns. + +CUA's driver is macOS-specific and still requires the local macOS permissions +that its app prompts for, such as Accessibility and Screen Recording. OpenClaw +does not install `cua-driver`, grant those permissions, or bypass the upstream +driver's safety model. + ## Quick setup Set `plugins.entries.codex.config.computerUse` when Codex-mode turns must have diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 1e1e7cf5b69..fd7916d7532 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -594,6 +594,11 @@ desktop actions itself. It prepares Codex app-server, verifies that the `computer-use` MCP server is available, and then lets Codex handle the native MCP tool calls during Codex-mode turns. +For direct TryCua driver access outside the Codex marketplace flow, register +`cua-driver mcp` with `openclaw mcp set cua-driver '{"command":"cua-driver","args":["mcp"]}'`. +See [Codex Computer Use](/plugins/codex-computer-use) for the distinction +between Codex-owned Computer Use and direct MCP registration. + Minimal config: ```json5