diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af25c30e43..92ce72e694c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- macOS/Voice input: guard all audio-input startup paths against missing default microphones (Voice Wake, Talk Mode, Push-to-Talk, mic-level monitor, tester) to avoid launch/runtime crashes on mic-less Macs and fail gracefully until input becomes available. (#25817) Thanks @sfo2001. - Gateway/Security: enforce gateway auth for the exact `/api/channels` plugin root path (plus `/api/channels/` descendants), with regression coverage for query/trailing-slash variants and near-miss paths that must remain plugin-owned. (#25753) Thanks @bmendonca3. - Security/Exec: sanitize inherited host execution environment before merge, canonicalize inherited PATH handling, and strip dangerous keys (`LD_*`, `DYLD_*`, `SSLKEYLOGFILE`, and related injection vectors) from non-sandboxed exec runs. (#25755) Thanks @bmendonca3. - Security/Hooks: normalize hook session-key classification with trim/lowercase plus Unicode NFKC folding (for example full-width `HOOK:...`) so external-content wrapping cannot be bypassed by mixed-case or lookalike prefixes. (#25750) Thanks @bmendonca3. diff --git a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift index abbddb24588..6c01628144b 100644 --- a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift +++ b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift @@ -53,6 +53,15 @@ final class AudioInputDeviceObserver { return output } + /// Returns true when the system default input device exists and is alive with input channels. + /// Use this preflight before accessing `AVAudioEngine.inputNode` to avoid SIGABRT on Macs + /// without a built-in microphone (Mac mini, Mac Pro, Mac Studio) or when an external mic + /// is disconnected. + static func hasUsableDefaultInputDevice() -> Bool { + guard let uid = self.defaultInputDeviceUID() else { return false } + return self.aliveInputDeviceUIDs().contains(uid) + } + static func defaultInputDeviceSummary() -> String { let systemObject = AudioObjectID(kAudioObjectSystemObject) var address = AudioObjectPropertyAddress( diff --git a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift index e35057d28cf..81e06abda2d 100644 --- a/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift +++ b/apps/macos/Sources/OpenClaw/MicLevelMonitor.swift @@ -14,6 +14,13 @@ actor MicLevelMonitor { if self.running { return } self.logger.info( "mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))") + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.engine = nil + throw NSError( + domain: "MicLevelMonitor", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } let engine = AVAudioEngine() self.engine = engine let input = engine.inputNode diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 443bc192295..70184ce9cc7 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -185,6 +185,12 @@ actor TalkModeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + self.logger.error("talk mode: no usable audio input device") + return + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) input.removeTap(onBus: 0) diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift index e535ebd6616..6eaa45e0675 100644 --- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift +++ b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift @@ -244,6 +244,14 @@ actor VoicePushToTalk { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoicePushToTalk", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) if self.tapInstalled { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift index 61f913b9da8..b7e2d329b82 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift @@ -166,6 +166,14 @@ actor VoiceWakeRuntime { } guard let audioEngine = self.audioEngine else { return } + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let input = audioEngine.inputNode let format = input.outputFormat(forBus: 0) guard format.channelCount > 0, format.sampleRate > 0 else { diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift index b3d0c58d90c..063fea826ab 100644 --- a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift +++ b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift @@ -89,6 +89,14 @@ final class VoiceWakeTester { self.logInputSelection(preferredMicID: micID) self.configureSession(preferredMicID: micID) + guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else { + self.audioEngine = nil + throw NSError( + domain: "VoiceWakeTester", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "No usable audio input device available"]) + } + let engine = AVAudioEngine() self.audioEngine = engine diff --git a/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift new file mode 100644 index 00000000000..a175e5e1a0a --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/AudioInputDeviceObserverTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite struct AudioInputDeviceObserverTests { + @Test func hasUsableDefaultInputDeviceReturnsBool() { + // Smoke test: verifies the composition logic runs without crashing. + // Actual result depends on whether the host has an audio input device. + let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice() + _ = result // suppress unused-variable warning; the assertion is "no crash" + } + + @Test func hasUsableDefaultInputDeviceConsistentWithComponents() { + // When no default UID exists, the method must return false. + // When a default UID exists, the result must match alive-set membership. + let uid = AudioInputDeviceObserver.defaultInputDeviceUID() + let alive = AudioInputDeviceObserver.aliveInputDeviceUIDs() + let expected = uid.map { alive.contains($0) } ?? false + #expect(AudioInputDeviceObserver.hasUsableDefaultInputDevice() == expected) + } +}