fix(lume): disable clipboard sync by default, add --clipboard opt-in

Clipboard sync is experimental and creates noisy SSH error logs when
the VM's SSH is unavailable. Disable it by default and add a
--clipboard flag to opt in.

- Add --clipboard flag to `lume run` (default: off)
- Add `clipboard` field to the HTTP API RunVMRequest
- Add `clipboard` param to the MCP server run handler
- Thread the flag through LumeController → VM.run
- Update API docs

Closes #1054
This commit is contained in:
f-trycua
2026-02-10 22:03:33 -08:00
parent 72ee88a7da
commit d696afe5fe
7 changed files with 27 additions and 12 deletions

View File

@@ -56,6 +56,9 @@ struct Run: AsyncParsableCommand {
help: "Optional network override: 'nat', 'bridged', or 'bridged:<interface>' (e.g. 'bridged:en0'). Defaults to the VM's configured mode.")
var network: String?
@Flag(name: .customLong("clipboard"), help: "Enable bidirectional clipboard sync with the VM via SSH (experimental)")
var clipboard: Bool = false
private var parsedNetworkMode: NetworkMode? {
get throws {
guard let network else {
@@ -133,7 +136,8 @@ struct Run: AsyncParsableCommand {
recoveryMode: recoveryMode,
storage: storage,
usbMassStoragePaths: parsedUSBStorageDevices.isEmpty ? nil : parsedUSBStorageDevices,
networkMode: parsedNetworkMode
networkMode: parsedNetworkMode,
clipboard: clipboard
)
}
}

View File

@@ -900,7 +900,8 @@ final class LumeController {
recoveryMode: Bool = false,
storage: String? = nil,
usbMassStoragePaths: [Path]? = nil,
networkMode: NetworkMode? = nil
networkMode: NetworkMode? = nil,
clipboard: Bool = false
) async throws {
let normalizedName = normalizeVMName(name: name)
Logger.info(
@@ -998,7 +999,8 @@ final class LumeController {
vncPort: vncPort,
recoveryMode: recoveryMode,
usbMassStoragePaths: usbMassStoragePaths,
networkMode: networkMode)
networkMode: networkMode,
clipboard: clipboard)
Logger.info("VM started successfully", metadata: ["name": normalizedName])
} catch {
SharedVM.shared.removeVM(name: normalizedName)

View File

@@ -306,7 +306,8 @@ enum APIDocExtractor {
APIFieldDoc(name: "noDisplay", type: "boolean", required: false, description: "Run without VNC display", defaultValue: "false"),
APIFieldDoc(name: "sharedDirectories", type: "array", required: false, description: "Directories to share with the VM", defaultValue: nil),
APIFieldDoc(name: "recoveryMode", type: "boolean", required: false, description: "Boot macOS VM in recovery mode", defaultValue: "false"),
APIFieldDoc(name: "storage", type: "string", required: false, description: "VM storage location", defaultValue: nil)
APIFieldDoc(name: "storage", type: "string", required: false, description: "VM storage location", defaultValue: nil),
APIFieldDoc(name: "clipboard", type: "boolean", required: false, description: "Enable bidirectional clipboard sync via SSH (experimental)", defaultValue: "false")
]
),
responseBody: APIResponseDoc(

View File

@@ -340,7 +340,7 @@ extension Server {
body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) }
?? RunVMRequest(
noDisplay: nil, sharedDirectories: nil, recoveryMode: nil, storage: nil,
network: nil)
network: nil, clipboard: nil)
// Record telemetry
TelemetryClient.shared.record(event: TelemetryEvent.apiVMRun, properties: [
@@ -372,7 +372,8 @@ extension Server {
sharedDirectories: dirs,
recoveryMode: request.recoveryMode ?? false,
storage: request.storage,
networkMode: networkMode
networkMode: networkMode,
clipboard: request.clipboard ?? false
)
Logger.info("VM start initiated in background", metadata: ["name": name])
@@ -825,7 +826,8 @@ extension Server {
sharedDirectories: [SharedDirectory] = [],
recoveryMode: Bool = false,
storage: String? = nil,
networkMode: NetworkMode? = nil
networkMode: NetworkMode? = nil,
clipboard: Bool = false
) {
Logger.info(
"Starting VM in detached task",
@@ -855,7 +857,8 @@ extension Server {
sharedDirectories: sharedDirectories,
recoveryMode: recoveryMode,
storage: storage,
networkMode: networkMode
networkMode: networkMode,
clipboard: clipboard
)
Logger.info("VM started successfully in background task", metadata: ["name": name])
} catch {

View File

@@ -565,6 +565,7 @@ final class LumeMCPServer {
let storage = args?["storage"]?.stringValue
let noDisplay = args?["no_display"]?.boolValue ?? true
let clipboard = args?["clipboard"]?.boolValue ?? false
var sharedDirectories: [SharedDirectory] = []
if let sharedDir = args?["shared_dir"]?.stringValue {
@@ -581,7 +582,8 @@ final class LumeMCPServer {
name: name,
noDisplay: noDisplay,
sharedDirectories: sharedDirectories,
storage: storage
storage: storage,
clipboard: clipboard
)
} catch {
Logger.error(

View File

@@ -17,6 +17,7 @@ struct RunVMRequest: Codable {
let recoveryMode: Bool?
let storage: String?
let network: String?
let clipboard: Bool?
struct SharedDirectoryRequest: Codable {
let hostPath: String

View File

@@ -136,7 +136,7 @@ class VM {
func run(
noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?, vncPort: Int = 0,
recoveryMode: Bool = false, usbMassStoragePaths: [Path]? = nil,
networkMode: NetworkMode? = nil
networkMode: NetworkMode? = nil, clipboard: Bool = false
) async throws {
Logger.info(
"VM.run method called",
@@ -261,8 +261,10 @@ class VM {
// Start clipboard watcher for automatic host-to-VM clipboard sync
// Requires SSH/Remote Login to be enabled on the VM
clipboardWatcher = ClipboardWatcher(vmName: vmDirContext.name, storage: vmDirContext.storage)
await clipboardWatcher?.start()
if clipboard {
clipboardWatcher = ClipboardWatcher(vmName: vmDirContext.name, storage: vmDirContext.storage)
await clipboardWatcher?.start()
}
while true {
try await Task.sleep(nanoseconds: UInt64(1e9))