fix: harden remote voice wake CLI lookup

This commit is contained in:
Peter Steinberger
2025-12-07 04:43:00 +01:00
parent 050ebb3b19
commit 55e0086958
3 changed files with 76 additions and 11 deletions

View File

@@ -10,11 +10,64 @@ struct VoiceWakeForwardConfig: Sendable {
}
enum VoiceWakeForwarder {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward")
private static let cliPathPrefix = "PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH"
private final class CLICache: @unchecked Sendable {
private var value: (target: String, path: String)?
private let lock = NSLock()
static func commandWithCliPath(_ command: String) -> String {
"\(self.cliPathPrefix); \(command)"
func get() -> (target: String, path: String)? {
self.lock.lock(); defer { self.lock.unlock() }
return self.value
}
func set(_ newValue: (target: String, path: String)?) {
self.lock.lock(); self.value = newValue; self.lock.unlock()
}
}
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward")
private static let cliSearchCandidates = ["clawdis-mac"] + cliHelperSearchPaths.map { "\($0)/clawdis-mac" }
private static let cliCache = CLICache()
static func clearCliCache() {
self.cliCache.set(nil)
}
private static func cliLookupPrefix(target: String, echoPath: Bool) -> String {
let normalizedTarget = target.trimmingCharacters(in: .whitespacesAndNewlines)
let pathPrefix = "PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH"
let searchList = self.cliSearchCandidates.joined(separator: " ")
var steps: [String] = [pathPrefix]
let cached = self.cliCache.get()
if let cached, cached.target == normalizedTarget {
steps.append("CLI=\"\(cached.path)\"")
steps.append("if [ ! -x \"$CLI\" ]; then CLI=\"\"; fi")
} else {
steps.append("CLI=\"\"")
}
steps.append("if [ -z \"$CLI\" ]; then CLI=$(command -v clawdis-mac 2>/dev/null || true); fi")
steps.append("if [ -z \"$CLI\" ]; then for c in \(searchList); do [ -x \"$c\" ] && CLI=\"$c\" && break; done; fi")
steps.append("if [ -z \"$CLI\" ]; then echo 'clawdis-mac missing'; exit 127; fi")
if echoPath {
steps.append("echo __CLI:$CLI")
}
return steps.joined(separator: "; ")
}
static func commandWithCliPath(_ command: String, target: String, echoCliPath: Bool = false) -> String {
let rewritten: String
if command.contains("clawdis-mac") {
rewritten = command.replacingOccurrences(of: "clawdis-mac", with: "\"$CLI\"")
} else {
rewritten = "\"$CLI\" \(command)"
}
return "\(self.cliLookupPrefix(target: target, echoPath: echoCliPath)); \(rewritten)"
}
enum VoiceWakeForwardError: LocalizedError, Equatable {
@@ -62,7 +115,7 @@ enum VoiceWakeForwarder {
args.append(userHost)
let rendered = self.renderedCommand(template: config.commandTemplate, transcript: transcript)
args.append(contentsOf: ["sh", "-c", self.commandWithCliPath(rendered)])
args.append(contentsOf: ["sh", "-c", self.commandWithCliPath(rendered, target: destination)])
self.logger.info("voice wake forward starting host=\(userHost, privacy: .public)")
@@ -139,7 +192,7 @@ enum VoiceWakeForwarder {
// Stage 2: ensure remote clawdis-mac is present and responsive.
var checkArgs = baseArgs
let statusCommand = self.commandWithCliPath("clawdis-mac status")
let statusCommand = self.commandWithCliPath("clawdis-mac status", target: destination, echoCliPath: true)
checkArgs.append(contentsOf: [userHost, "sh", "-c", statusCommand])
let checkProc = Process()
checkProc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
@@ -154,6 +207,14 @@ enum VoiceWakeForwarder {
}
let statusOut = await self.wait(checkProc, timeout: 6, capturing: checkPipe)
if checkProc.terminationStatus == 0 {
if let cliLine = statusOut
.split(separator: "\n")
.last(where: { $0.hasPrefix("__CLI:") }) {
let path = String(cliLine.dropFirst("__CLI:".count))
if !path.isEmpty {
self.cliCache.set((target: destination, path: path))
}
}
return .success(())
}
return .failure(.cliMissingOrFailed(checkProc.terminationStatus, statusOut))

View File

@@ -755,7 +755,10 @@ struct VoiceWakeSettings: View {
TextField("steipete@peters-mac-studio-1", text: self.$state.voiceWakeForwardTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
.onChange(of: self.state.voiceWakeForwardTarget) { _, _ in self.forwardStatus = .idle }
.onChange(of: self.state.voiceWakeForwardTarget) { _, _ in
self.forwardStatus = .idle
VoiceWakeForwarder.clearCliCache()
}
self.forwardStatusIcon
.frame(width: 16, height: 16, alignment: .center)
Button("Test") {
@@ -837,6 +840,7 @@ struct VoiceWakeSettings: View {
}
private func checkForwardConnection() async {
VoiceWakeForwarder.clearCliCache()
self.forwardStatus = .checking
let config = AppStateStore.shared.voiceWakeForwardConfig
let result = await VoiceWakeForwarder.checkConnection(config: config)

View File

@@ -31,9 +31,9 @@ import Testing
}
@Test func commandPrefersCliInstallPaths() {
let command = VoiceWakeForwarder.commandWithCliPath("clawdis-mac status")
let prefix = "PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH; "
#expect(command.hasPrefix(prefix))
#expect(command.contains("clawdis-mac status"))
let command = VoiceWakeForwarder.commandWithCliPath("clawdis-mac status", target: "user@host")
#expect(command.contains("PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH"))
#expect(command.contains("for c in clawdis-mac /usr/local/bin/clawdis-mac /opt/homebrew/bin/clawdis-mac"))
#expect(command.contains("\"$CLI\" status"))
}
}