fix: harden remote voice wake CLI lookup
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user