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 { enum VoiceWakeForwarder {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.forward") private final class CLICache: @unchecked Sendable {
private static let cliPathPrefix = "PATH=\(cliHelperSearchPaths.joined(separator: ":")):$PATH" private var value: (target: String, path: String)?
private let lock = NSLock()
static func commandWithCliPath(_ command: String) -> String { func get() -> (target: String, path: String)? {
"\(self.cliPathPrefix); \(command)" 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 { enum VoiceWakeForwardError: LocalizedError, Equatable {
@@ -62,7 +115,7 @@ enum VoiceWakeForwarder {
args.append(userHost) args.append(userHost)
let rendered = self.renderedCommand(template: config.commandTemplate, transcript: transcript) 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)") 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. // Stage 2: ensure remote clawdis-mac is present and responsive.
var checkArgs = baseArgs 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]) checkArgs.append(contentsOf: [userHost, "sh", "-c", statusCommand])
let checkProc = Process() let checkProc = Process()
checkProc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") checkProc.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
@@ -154,6 +207,14 @@ enum VoiceWakeForwarder {
} }
let statusOut = await self.wait(checkProc, timeout: 6, capturing: checkPipe) let statusOut = await self.wait(checkProc, timeout: 6, capturing: checkPipe)
if checkProc.terminationStatus == 0 { 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 .success(())
} }
return .failure(.cliMissingOrFailed(checkProc.terminationStatus, statusOut)) 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) TextField("steipete@peters-mac-studio-1", text: self.$state.voiceWakeForwardTarget)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity) .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 self.forwardStatusIcon
.frame(width: 16, height: 16, alignment: .center) .frame(width: 16, height: 16, alignment: .center)
Button("Test") { Button("Test") {
@@ -837,6 +840,7 @@ struct VoiceWakeSettings: View {
} }
private func checkForwardConnection() async { private func checkForwardConnection() async {
VoiceWakeForwarder.clearCliCache()
self.forwardStatus = .checking self.forwardStatus = .checking
let config = AppStateStore.shared.voiceWakeForwardConfig let config = AppStateStore.shared.voiceWakeForwardConfig
let result = await VoiceWakeForwarder.checkConnection(config: config) let result = await VoiceWakeForwarder.checkConnection(config: config)

View File

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