fix: harden mac app computer use docs

This commit is contained in:
Peter Steinberger
2026-04-28 01:24:14 +01:00
parent 864c4f7ff4
commit c72f8f357b
30 changed files with 636 additions and 362 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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(

View File

@@ -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"
}
},
{

View File

@@ -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
}
}

View File

@@ -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<T>(_ 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,6 +277,7 @@ enum ExecApprovalsStore {
}
static func readSnapshot() -> ExecApprovalsSnapshot {
self.withFileLock {
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
@@ -281,7 +289,9 @@ enum ExecApprovalsStore {
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 {
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: [:])
@@ -292,6 +302,7 @@ enum ExecApprovalsStore {
hash: self.hashRaw(raw),
file: decoded)
}
}
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -310,6 +321,7 @@ enum ExecApprovalsStore {
}
static func loadFile() -> ExecApprovalsFile {
self.withFileLock {
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
@@ -326,8 +338,10 @@ enum ExecApprovalsStore {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
}
}
static func saveFile(_ file: ExecApprovalsFile) {
self.withFileLock {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
@@ -343,8 +357,10 @@ enum ExecApprovalsStore {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
}
}
}
static func ensureFile() -> ExecApprovalsFile {
self.withFileLock {
self.ensureSecureStateDirectory()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)
@@ -367,6 +383,7 @@ enum ExecApprovalsStore {
}
return file
}
}
static func resolve(agentId: String?) -> ExecApprovalsResolved {
let file = self.ensureFile()
@@ -533,10 +550,12 @@ enum ExecApprovalsStore {
}
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
self.withFileLock {
var file = self.ensureFile()
mutate(&file)
self.saveFile(file)
}
}
private static func ensureSecureStateDirectory() {
let url = OpenClawPaths.stateDirURL

View File

@@ -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<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
defer { self.fileLock.unlock() }
return try body()
}
#if DEBUG
static func withTestingFileLock<T>(_ body: () throws -> T) rethrows -> T {
try self.withFileLock(body)
}
#endif
static func url() -> URL {
OpenClawPaths.configURL
@@ -20,6 +33,7 @@ enum OpenClawConfigFile {
}
static func loadDict() -> [String: Any] {
self.withFileLock {
let url = self.url()
guard FileManager().fileExists(atPath: url.path) else { return [:] }
do {
@@ -36,8 +50,10 @@ enum OpenClawConfigFile {
return [:]
}
}
}
static func saveDict(_ dict: [String: Any]) {
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()
@@ -112,6 +128,7 @@ enum OpenClawConfigFile {
])
}
}
}
static func loadGatewayDict() -> [String: Any] {
let root = self.loadDict()

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)",

View File

@@ -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",

View File

@@ -84,7 +84,7 @@ struct ExecSkillBinTrustTests {
requirements: SkillRequirements(bins: bins, env: [], config: []),
missing: SkillMissing(bins: [], env: [], config: []),
configChecks: [],
install: [])
install: []),
])
}
}

View File

@@ -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
}
}
}

View File

@@ -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")
}

View File

@@ -37,6 +37,8 @@ struct GatewayProcessManagerTests {
}
@Test func `attaches to existing gateway without spawning launchd`() async throws {
let port = 19097
try await TestIsolation.withEnvValues(["OPENCLAW_GATEWAY_PORT": "\(port)"]) {
let healthData = Data(
"""
{
@@ -83,7 +85,6 @@ struct GatewayProcessManagerTests {
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",
@@ -95,12 +96,13 @@ struct GatewayProcessManagerTests {
manager.setTestingSkipControlChannelRefresh(true)
manager.setTestingLastFailureReason("stale")
@MainActor
func cleanup() async {
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
manager.setTestingConnection(nil)
manager.setTestingSkipControlChannelRefresh(false)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
await PortGuardian.shared.setTestingDescriptor(nil, forPort: port)
}
do {
@@ -123,4 +125,5 @@ struct GatewayProcessManagerTests {
throw error
}
}
}
}

View File

@@ -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 = """
{

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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)

View File

@@ -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")

View File

@@ -176,6 +176,7 @@ struct OpenClawConfigFileTests {
"OPENCLAW_STATE_DIR": stateDir.path,
"OPENCLAW_CONFIG_PATH": configPath.path,
]) {
try OpenClawConfigFile.withTestingFileLock {
OpenClawConfigFile.saveDict([
"update": ["channel": "beta"],
"browser": ["enabled": true],
@@ -230,4 +231,5 @@ struct OpenClawConfigFileTests {
}
}
}
}
}

View File

@@ -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"))
}

View File

@@ -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)

View File

@@ -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 = "嘿 小爪 帮我打开设置"

View File

@@ -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 })
}
}

View File

@@ -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 apps 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:

View File

@@ -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

View File

@@ -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