mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix: harden mac app computer use docs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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,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,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -84,7 +84,7 @@ struct ExecSkillBinTrustTests {
|
||||
requirements: SkillRequirements(bins: bins, env: [], config: []),
|
||||
missing: SkillMissing(bins: [], env: [], config: []),
|
||||
configChecks: [],
|
||||
install: [])
|
||||
install: []),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = "嘿 小爪 帮我打开设置"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user