@@ -0,0 +1,1216 @@
9+ io::{AsRawFd, IntoRawFd, OwnedFd},
14+ path::{Path, PathBuf},
15+ process::{Command, Stdio},
18+ mpsc::{self, Receiver, Sender},
21+ time::{Duration, Instant},
25+use dispatch2::DispatchQueue;
26+use lexopt::prelude::*;
27+use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28+use objc2_foundation::*;
29+use objc2_virtualization::*;
31+const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/trixie/20260112-2355/debian-13-nocloud-arm64-20260112-2355.tar.xz";
32+const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
33+const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34+const SHARED_DIRECTORIES_TAG: &str = "shared";
36+const BYTES_PER_MB: u64 = 1024 * 1024;
37+const DEFAULT_CPU_COUNT: usize = 2;
38+const DEFAULT_RAM_MB: u64 = 2048;
39+const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
40+const START_TIMEOUT: Duration = Duration::from_secs(60);
41+const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
42+const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
43+const PROVISION_SCRIPT: &str = include_str!("provision.sh");
47+ Expect { text: String, timeout: Duration },
49+ Script { path: PathBuf, index: usize },
54+struct DirectoryShare {
65+ ) -> Result<Self, Box<dyn std::error::Error>> {
67+ return Err(format!("Host path does not exist: {}", host.display()).into());
69+ if !guest.is_absolute() {
70+ guest = PathBuf::from("/root").join(guest);
79+ fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
80+ let parts: Vec<&str> = spec.split(':').collect();
81+ if parts.len() < 2 || parts.len() > 3 {
82+ return Err(format!("Invalid mount spec: {spec}").into());
84+ let host = PathBuf::from(parts[0]);
85+ let guest = PathBuf::from(parts[1]);
86+ let read_only = if parts.len() == 3 {
89+ "read-write" => false,
92+ "Invalid mount mode '{}'; expected read-only or read-write",
101+ DirectoryShare::new(host, guest, read_only)
104+ fn tag(&self) -> String {
105+ let path_str = self.host.to_string_lossy();
108+ .fold(5381u64, |h, b| h.wrapping_mul(33).wrapping_add(b as u64));
109+ let base_name = self
112+ .map(|s| s.to_string_lossy())
113+ .unwrap_or("share".into());
114+ format!("{}_{:016x}", base_name, hash)
118+fn main() -> Result<(), Box<dyn std::error::Error>> {
119+ let args = parse_cli()?;
123+ println!("https://github.com/lynaghk/vibe/");
124+ println!("Git SHA: {}", env!("GIT_SHA"));
125+ std::process::exit(0);
130+ "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
132+vibe [OPTIONS] [disk-image.raw]
136+ --help Print this help message.
137+ --version Print the version (commit SHA).
138+ --no-default-mounts Disable all default mounts.
139+ --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
140+ Defaults to read-write.
141+ Errors if host-path does not exist.
142+ --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
143+ --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
144+ --script <path/to/script.sh> Run script in VM.
145+ --send <some-command> Type `some-command` followed by newline into the VM.
146+ --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
147+ If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
150+ std::process::exit(0);
155+ let project_root = env::current_dir()?;
156+ let project_name = project_root
158+ .ok_or("Project directory has no name")?
162+ let home = env::var("HOME").map(PathBuf::from)?;
163+ let cache_home = env::var("XDG_CACHE_HOME")
165+ .unwrap_or_else(|_| home.join(".cache"));
166+ let cache_dir = cache_home.join("vibe");
167+ let guest_mise_cache = cache_dir.join(".guest-mise-cache");
169+ let instance_dir = project_root.join(".vibe");
171+ let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
172+ let base_compressed = cache_dir.join(basename_compressed);
173+ let base_raw = cache_dir.join(format!(
175+ basename_compressed.trim_end_matches(".tar.xz")
178+ let default_raw = cache_dir.join("default.raw");
179+ let instance_raw = instance_dir.join("instance.raw");
181+ // Prepare system-wide directories
182+ fs::create_dir_all(&cache_dir)?;
183+ fs::create_dir_all(&guest_mise_cache)?;
185+ let mise_directory_share =
186+ DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
188+ let disk_path = if let Some(path) = args.disk {
190+ return Err(format!("Disk image does not exist: {}", path.display()).into());
194+ ensure_default_image(
198+ std::slice::from_ref(&mise_directory_share),
200+ ensure_instance_disk(&instance_raw, &default_raw)?;
205+ let mut login_actions = Vec::new();
206+ let mut directory_shares = Vec::new();
208+ if !args.no_default_mounts {
209+ login_actions.push(Send(format!("cd {project_name}")));
211+ // discourage read/write of .git folder from within the VM. note that this isn't secure, since the VM runs as root and could unmount this.
212+ // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
213+ if project_root.join(".git").exists() {
214+ login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
217+ directory_shares.push(
218+ DirectoryShare::new(
220+ PathBuf::from("/root/").join(project_name),
223+ .expect("Project directory must exist"),
226+ directory_shares.push(mise_directory_share);
228+ // Add default shares, if they exist
230+ DirectoryShare::new(home.join(".m2"), "/root/.m2".into(), false),
231+ DirectoryShare::new(
232+ home.join(".cargo/registry"),
233+ "/root/.cargo/registry".into(),
236+ DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
237+ DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
242+ directory_shares.push(share)
246+ for spec in &args.mounts {
247+ directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
250+ if let Some(motd_action) = motd_login_action(&directory_shares) {
251+ login_actions.push(motd_action);
254+ // Any user-provided login actions must come after our system ones
255+ login_actions.extend(args.login_actions);
260+ &directory_shares[..],
267+ disk: Option<PathBuf>,
270+ no_default_mounts: bool,
271+ mounts: Vec<String>,
272+ login_actions: Vec<LoginAction>,
277+fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
278+ fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
281+ .map_err(|_| format!("{flag} expects valid UTF-8").into())
284+ let mut parser = lexopt::Parser::from_env();
285+ let mut disk = None;
286+ let mut version = false;
287+ let mut help = false;
288+ let mut no_default_mounts = false;
289+ let mut mounts = Vec::new();
290+ let mut login_actions = Vec::new();
291+ let mut script_index = 0;
292+ let mut cpu_count = DEFAULT_CPU_COUNT;
293+ let mut ram_bytes = DEFAULT_RAM_BYTES;
295+ while let Some(arg) = parser.next()? {
297+ Long("version") => version = true,
298+ Long("help") | Short('h') => help = true,
299+ Long("no-default-mounts") => no_default_mounts = true,
301+ let value = os_to_string(parser.value()?, "--cpus")?.parse()?;
303+ return Err("--cpus must be >= 1".into());
308+ let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?;
310+ return Err("--ram must be >= 1".into());
312+ ram_bytes = value * BYTES_PER_MB;
315+ mounts.push(os_to_string(parser.value()?, "--mount")?);
318+ login_actions.push(Script {
319+ path: os_to_string(parser.value()?, "--script")?.into(),
320+ index: script_index,
325+ login_actions.push(Send(os_to_string(parser.value()?, "--send")?));
328+ let text = os_to_string(parser.value()?, "--expect")?;
329+ let timeout = match parser.optional_value() {
330+ Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?),
331+ None => DEFAULT_EXPECT_TIMEOUT,
333+ login_actions.push(Expect { text, timeout });
337+ return Err("Only one disk path may be provided".into());
339+ disk = Some(PathBuf::from(value));
341+ _ => return Err(arg.unexpected().into()),
357+fn script_command_from_path(
360+) -> Result<String, Box<dyn std::error::Error>> {
361+ let script = fs::read_to_string(path)
362+ .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
363+ let label = format!("{}_{}", index, path.file_name().unwrap().display());
364+ script_command_from_content(&label, &script)
367+fn script_command_from_content(
370+) -> Result<String, Box<dyn std::error::Error>> {
371+ let marker = "VIBE_SCRIPT_EOF";
372+ let guest_dir = "/tmp/vibe-scripts";
373+ let guest_path = format!("{guest_dir}/{label}.sh");
374+ let command = format!(
375+ "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
377+ if script.contains(marker) {
379+ format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
385+fn motd_login_action(directory_shares: &[DirectoryShare]) -> Option<LoginAction> {
386+ if directory_shares.is_empty() {
387+ return Some(Send("clear".into()));
390+ let host_header = "Host";
391+ let guest_header = "Guest";
392+ let mode_header = "Mode";
393+ let mut host_width = host_header.len();
394+ let mut guest_width = guest_header.len();
395+ let mut mode_width = mode_header.len();
396+ let mut rows = Vec::with_capacity(directory_shares.len());
398+ for share in directory_shares {
399+ let host = share.host.to_string_lossy().into_owned();
400+ let guest = share.guest.to_string_lossy().into_owned();
401+ let mode = if share.read_only {
407+ host_width = host_width.max(host.len());
408+ guest_width = guest_width.max(guest.len());
409+ mode_width = mode_width.max(mode.len());
410+ rows.push((host, guest, mode));
413+ let mut output = String::new();
416+░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
417+░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
418+ ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
419+ ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░
420+ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
421+ ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
422+ ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
426+ output.push_str(&format!(
427+ "{host_header:<host_width$} {guest_header:<guest_width$} {mode_header}\n",
428+ host_width = host_width
430+ output.push_str(&format!(
431+ "{:-<host_width$} {:-<guest_width$} {:-<mode_width$}\n",
435+ host_width = host_width,
436+ guest_width = guest_width,
437+ mode_width = mode_width
440+ for (host, guest, mode) in rows {
441+ output.push_str(&format!(
442+ "{host:<host_width$} {guest:<guest_width$} {mode}\n"
446+ let command = format!("clear && cat <<'VIBE_MOTD'\n{output}\nVIBE_MOTD");
450+#[derive(PartialEq, Eq)]
462+ LoginActionTimeout { action: String, timeout: Duration },
466+pub struct OutputMonitor {
467+ buffer: Mutex<String>,
472+ fn push(&self, bytes: &[u8]) {
476+ .push_str(&String::from_utf8_lossy(bytes));
477+ self.condvar.notify_all();
480+ fn wait_for(&self, needle: &str, timeout: Duration) -> WaitResult {
481+ let (_unused, timeout_result) = self
483+ .wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
484+ if let Some((_, remaining)) = buf.split_once(needle) {
485+ *buf = remaining.to_string();
493+ if timeout_result.timed_out() {
501+fn ensure_base_image(
503+ base_compressed: &Path,
504+) -> Result<(), Box<dyn std::error::Error>> {
505+ if base_raw.exists() {
509+ if !base_compressed.exists()
510+ || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
512+ println!("Downloading base image...");
513+ let status = Command::new("curl")
521+ &base_compressed.to_string_lossy(),
522+ DEBIAN_COMPRESSED_DISK_URL,
525+ if !status.success() {
526+ return Err("Failed to download base image".into());
532+ let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
534+ let mut child = Command::new("/usr/bin/shasum")
535+ .args(["--algorithm", "512", "--check"])
536+ .stdin(Stdio::piped())
538+ .expect("failed to spawn shasum");
543+ .expect("failed to open stdin")
544+ .write_all(input.as_bytes())
545+ .expect("failed to write to stdin");
547+ let status = child.wait().expect("failed to wait on child");
548+ if !status.success() {
549+ return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
553+ println!("Decompressing base image...");
554+ let status = Command::new("tar")
555+ .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"])
556+ .stdout(std::fs::File::create(base_raw)?)
559+ if !status.success() {
560+ return Err("Failed to decompress base image".into());
566+fn ensure_default_image(
568+ base_compressed: &Path,
570+ directory_shares: &[DirectoryShare],
571+) -> Result<(), Box<dyn std::error::Error>> {
572+ if default_raw.exists() {
576+ ensure_base_image(base_raw, base_compressed)?;
578+ println!("Configuring base image...");
579+ fs::copy(base_raw, default_raw)?;
581+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
584+ &[Send(provision_command)],
593+fn ensure_instance_disk(
594+ instance_raw: &Path,
595+ template_raw: &Path,
596+) -> Result<(), Box<dyn std::error::Error>> {
597+ if instance_raw.exists() {
601+ println!("Creating instance disk from {}...", template_raw.display());
602+ std::fs::create_dir_all(instance_raw.parent().unwrap())?;
603+ fs::copy(template_raw, instance_raw)?;
607+pub struct IoContext {
608+ pub input_tx: Sender<VmInput>,
609+ wakeup_write: OwnedFd,
610+ stdin_thread: thread::JoinHandle<()>,
611+ mux_thread: thread::JoinHandle<()>,
612+ stdout_thread: thread::JoinHandle<()>,
615+pub fn create_pipe() -> (OwnedFd, OwnedFd) {
616+ let (read_stream, write_stream) = UnixStream::pair().expect("Failed to create socket pair");
617+ (read_stream.into(), write_stream.into())
621+ output_monitor: Arc<OutputMonitor>,
622+ vm_output_fd: OwnedFd,
623+ vm_input_fd: OwnedFd,
625+ let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
627+ // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
628+ let raw_guard = Arc::new(Mutex::new(None));
630+ let (wakeup_read, wakeup_write) = create_pipe();
632+ enum PollResult<'a> {
639+ fn poll_with_wakeup<'a>(main_fd: RawFd, wakeup_fd: RawFd, buf: &'a mut [u8]) -> PollResult<'a> {
643+ events: libc::POLLIN,
648+ events: libc::POLLIN,
653+ let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
654+ if ret <= 0 || fds[1].revents & libc::POLLIN != 0 {
655+ PollResult::Shutdown
656+ } else if fds[0].revents & libc::POLLIN != 0 {
657+ let n = unsafe { libc::read(main_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
661+ PollResult::Shutdown
663+ PollResult::Ready(&buf[..(n as usize)])
666+ PollResult::Spurious
670+ // Copies from stdin to the VM; also polls wakeup_read to exit the thread when it's time to shutdown.
671+ let stdin_thread = thread::spawn({
672+ let input_tx = input_tx.clone();
673+ let raw_guard = raw_guard.clone();
674+ let wakeup_read = wakeup_read.try_clone().unwrap();
677+ let mut buf = [0u8; 1024];
679+ match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
680+ PollResult::Shutdown | PollResult::Error => break,
681+ PollResult::Spurious => continue,
682+ PollResult::Ready(bytes) => {
683+ if raw_guard.lock().unwrap().is_none() {
686+ if input_tx.send(VmInput::Bytes(bytes.to_vec())).is_err() {
695+ // Copies VM output to stdout; also polls wakeup_read to exit the thread when it's time to shutdown.
696+ let stdout_thread = thread::spawn({
697+ let raw_guard = raw_guard.clone();
698+ let wakeup_read = wakeup_read.try_clone().unwrap();
701+ let mut stdout = std::io::stdout().lock();
702+ let mut buf = [0u8; 1024];
704+ match poll_with_wakeup(vm_output_fd.as_raw_fd(), wakeup_read.as_raw_fd(), &mut buf)
706+ PollResult::Shutdown | PollResult::Error => break,
707+ PollResult::Spurious => continue,
708+ PollResult::Ready(bytes) => {
709+ // enable raw mode, if we haven't already
710+ if raw_guard.lock().unwrap().is_none()
711+ && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
713+ *raw_guard.lock().unwrap() = Some(guard);
716+ if stdout.write_all(bytes).is_err() {
719+ let _ = stdout.flush();
720+ output_monitor.push(bytes);
727+ // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
728+ let mux_thread = thread::spawn(move || {
729+ let mut vm_writer = std::fs::File::from(vm_input_fd);
731+ match input_rx.recv() {
732+ Ok(VmInput::Bytes(data)) => {
733+ if vm_writer.write_all(&data).is_err() {
737+ Ok(VmInput::Shutdown) => break,
753+ pub fn shutdown(self) {
754+ let _ = self.input_tx.send(VmInput::Shutdown);
755+ unsafe { libc::write(self.wakeup_write.as_raw_fd(), b"x".as_ptr() as *const _, 1) };
756+ let _ = self.stdin_thread.join();
757+ let _ = self.stdout_thread.join();
758+ let _ = self.mux_thread.join();
762+fn create_vm_configuration(
764+ directory_shares: &[DirectoryShare],
765+ vm_reads_from_fd: OwnedFd,
766+ vm_writes_to_fd: OwnedFd,
769+) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
772+ VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
774+ let boot_loader = VZEFIBootLoader::init(VZEFIBootLoader::alloc());
775+ let variable_store = load_efi_variable_store()?;
776+ boot_loader.setVariableStore(Some(&variable_store));
778+ let config = VZVirtualMachineConfiguration::new();
779+ config.setPlatform(&platform);
780+ config.setBootLoader(Some(&boot_loader));
781+ config.setCPUCount(cpu_count as NSUInteger);
782+ config.setMemorySize(ram_bytes);
784+ config.setNetworkDevices(&NSArray::from_retained_slice(&[{
785+ let network_device = VZVirtioNetworkDeviceConfiguration::new();
786+ network_device.setAttachment(Some(&VZNATNetworkDeviceAttachment::new()));
787+ Retained::into_super(network_device)
790+ config.setEntropyDevices(&NSArray::from_retained_slice(&[Retained::into_super(
791+ VZVirtioEntropyDeviceConfiguration::new(),
794+ ////////////////////////////
797+ let disk_attachment = VZDiskImageStorageDeviceAttachment::initWithURL_readOnly_cachingMode_synchronizationMode_error(
798+ VZDiskImageStorageDeviceAttachment::alloc(),
799+ &nsurl_from_path(disk_path).unwrap(),
801+ VZDiskImageCachingMode::Automatic,
802+ VZDiskImageSynchronizationMode::Full,
805+ let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
806+ VZVirtioBlockDeviceConfiguration::alloc(),
810+ let storage_devices: Retained<NSArray<_>> =
811+ NSArray::from_retained_slice(&[Retained::into_super(disk_device)]);
813+ config.setStorageDevices(&storage_devices);
816+ ////////////////////////////
819+ if !directory_shares.is_empty() {
820+ let directories: Retained<NSMutableDictionary<NSString, VZSharedDirectory>> =
821+ NSMutableDictionary::new();
823+ for share in directory_shares.iter() {
825+ share.host.is_dir(),
826+ "path does not exist or is not a directory: {:?}",
830+ let url = nsurl_from_path(&share.host)?;
831+ let shared_directory = VZSharedDirectory::initWithURL_readOnly(
832+ VZSharedDirectory::alloc(),
837+ let key = NSString::from_str(&share.tag());
838+ directories.setObject_forKey(&*shared_directory, ProtocolObject::from_ref(&*key));
841+ let multi_share = VZMultipleDirectoryShare::initWithDirectories(
842+ VZMultipleDirectoryShare::alloc(),
846+ let device = VZVirtioFileSystemDeviceConfiguration::initWithTag(
847+ VZVirtioFileSystemDeviceConfiguration::alloc(),
848+ &NSString::from_str(SHARED_DIRECTORIES_TAG),
850+ device.setShare(Some(&multi_share));
852+ let share_devices = NSArray::from_retained_slice(&[device.into_super()]);
853+ config.setDirectorySharingDevices(&share_devices);
856+ ////////////////////////////
859+ let ns_read_handle = NSFileHandle::initWithFileDescriptor_closeOnDealloc(
860+ NSFileHandle::alloc(),
861+ vm_reads_from_fd.into_raw_fd(),
865+ let ns_write_handle = NSFileHandle::initWithFileDescriptor_closeOnDealloc(
866+ NSFileHandle::alloc(),
867+ vm_writes_to_fd.into_raw_fd(),
872+ VZFileHandleSerialPortAttachment::initWithFileHandleForReading_fileHandleForWriting(
873+ VZFileHandleSerialPortAttachment::alloc(),
874+ Some(&ns_read_handle),
875+ Some(&ns_write_handle),
877+ let serial_port = VZVirtioConsoleDeviceSerialPortConfiguration::new();
878+ serial_port.setAttachment(Some(&serial_attach));
880+ let serial_ports: Retained<NSArray<_>> =
881+ NSArray::from_retained_slice(&[Retained::into_super(serial_port)]);
883+ config.setSerialPorts(&serial_ports);
886+ ////////////////////////////
888+ config.validateWithError().map_err(|e| {
889+ io::Error::other(format!(
890+ "Invalid VM configuration: {:?}",
891+ e.localizedDescription()
899+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
901+ let temp_dir = std::env::temp_dir();
902+ let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
903+ let url = nsurl_from_path(&temp_path)?;
904+ let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
905+ let store = VZEFIVariableStore::initCreatingVariableStoreAtURL_options_error(
906+ VZEFIVariableStore::alloc(),
914+fn spawn_login_actions_thread(
915+ login_actions: Vec<LoginAction>,
916+ output_monitor: Arc<OutputMonitor>,
917+ input_tx: mpsc::Sender<VmInput>,
918+ vm_output_tx: mpsc::Sender<VmOutput>,
919+) -> thread::JoinHandle<()> {
920+ thread::spawn(move || {
921+ for a in login_actions {
923+ Expect { text, timeout } => {
924+ if WaitResult::Timeout == output_monitor.wait_for(&text, timeout) {
925+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
926+ action: format!("expect '{}'", text),
933+ text.push('\n'); // Type the newline so the command is actually submitted.
934+ input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
936+ Script { path, index } => {
937+ let command = match script_command_from_path(&path, index) {
938+ Ok(command) => command,
944+ let mut text = command;
946+ input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
955+ login_actions: &[LoginAction],
956+ directory_shares: &[DirectoryShare],
959+) -> Result<(), Box<dyn std::error::Error>> {
960+ let (vm_reads_from, we_write_to) = create_pipe();
961+ let (we_read_from, vm_writes_to) = create_pipe();
963+ let config = create_vm_configuration(
972+ let queue = DispatchQueue::main();
975+ VZVirtualMachine::initWithConfiguration_queue(VZVirtualMachine::alloc(), &config, queue)
978+ let (tx, rx) = mpsc::channel::<Result<(), String>>();
979+ let completion_handler = RcBlock::new(move |error: *mut NSError| {
980+ if error.is_null() {
981+ let _ = tx.send(Ok(()));
983+ let err = unsafe { &*error };
984+ let _ = tx.send(Err(format!("{:?}", err.localizedDescription())));
989+ vm.startWithCompletionHandler(&completion_handler);
992+ let start_deadline = Instant::now() + START_TIMEOUT;
993+ while Instant::now() < start_deadline {
995+ NSRunLoop::mainRunLoop().runMode_beforeDate(
996+ NSDefaultRunLoopMode,
997+ &NSDate::dateWithTimeIntervalSinceNow(0.1),
1001+ match rx.try_recv() {
1003+ result.map_err(|e| format!("Failed to start VM: {}", e))?;
1006+ Err(mpsc::TryRecvError::Empty) => continue,
1007+ Err(mpsc::TryRecvError::Disconnected) => {
1008+ return Err("VM start channel disconnected".into());
1013+ if Instant::now() >= start_deadline {
1014+ return Err("Timed out waiting for VM to start".into());
1017+ println!("VM booting...");
1019+ let output_monitor = Arc::new(OutputMonitor::default());
1020+ let io_ctx = spawn_vm_io(output_monitor.clone(), we_read_from, we_write_to);
1022+ let mut all_login_actions = vec![
1024+ text: "login: ".to_string(),
1025+ timeout: LOGIN_EXPECT_TIMEOUT,
1027+ Send("root".to_string()),
1029+ text: "~#".to_string(),
1030+ timeout: LOGIN_EXPECT_TIMEOUT,
1034+ if !directory_shares.is_empty() {
1035+ all_login_actions.push(Send("mkdir -p /mnt/shared".into()));
1036+ all_login_actions.push(Send(format!(
1037+ "mount -t virtiofs {} /mnt/shared",
1038+ SHARED_DIRECTORIES_TAG
1041+ for share in directory_shares {
1042+ let staging = format!("/mnt/shared/{}", share.tag());
1043+ let guest = share.guest.to_string_lossy();
1044+ all_login_actions.push(Send(format!("mkdir -p {}", guest)));
1045+ all_login_actions.push(Send(format!("mount --bind {} {}", staging, guest)));
1049+ for a in login_actions {
1050+ all_login_actions.push(a.clone())
1053+ let (vm_output_tx, vm_output_rx) = mpsc::channel::<VmOutput>();
1054+ let login_actions_thread = spawn_login_actions_thread(
1056+ output_monitor.clone(),
1057+ io_ctx.input_tx.clone(),
1061+ let mut last_state = None;
1062+ let mut exit_result = Ok(());
1065+ NSRunLoop::mainRunLoop().runMode_beforeDate(
1066+ NSDefaultRunLoopMode,
1067+ &NSDate::dateWithTimeIntervalSinceNow(0.2),
1071+ let state = unsafe { vm.state() };
1072+ if last_state != Some(state) {
1073+ //eprintln!("[state] {:?}", state);
1074+ last_state = Some(state);
1076+ match vm_output_rx.try_recv() {
1077+ Ok(VmOutput::LoginActionTimeout { action, timeout }) => {
1078+ exit_result = Err(format!(
1079+ "Login action ({}) timed out after {:?}; shutting down.",
1084+ if vm.canRequestStop() {
1085+ if let Err(err) = vm.requestStopWithError() {
1086+ eprintln!("Failed to request VM stop: {:?}", err);
1088+ } else if vm.canStop() {
1089+ let handler = RcBlock::new(|_error: *mut NSError| {});
1090+ vm.stopWithCompletionHandler(&handler);
1095+ Err(mpsc::TryRecvError::Empty) => {}
1096+ Err(mpsc::TryRecvError::Disconnected) => {}
1098+ if state != objc2_virtualization::VZVirtualMachineState::Running {
1099+ //eprintln!("VM stopped with state: {:?}", state);
1104+ let _ = login_actions_thread.join();
1111+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1112+ let abs_path = if path.is_absolute() {
1115+ env::current_dir()?.join(path)
1117+ let ns_path = NSString::from_str(
1120+ .ok_or("Non-UTF8 path encountered while building NSURL")?,
1122+ Ok(NSURL::fileURLWithPath(&ns_path))
1125+fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1126+ let mut attributes: libc::termios = unsafe { std::mem::zeroed() };
1128+ if unsafe { libc::tcgetattr(fd, &mut attributes) } != 0 {
1129+ return Err(io::Error::last_os_error());
1132+ let original = attributes;
1134+ // Disable translation of carriage return to newline on input
1135+ attributes.c_iflag &= !(libc::ICRNL);
1136+ // Disable canonical mode (line buffering), echo, and signal generation
1137+ attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1138+ attributes.c_cc[libc::VMIN] = 0;
1139+ attributes.c_cc[libc::VTIME] = 1;
1141+ if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &attributes) } != 0 {
1142+ return Err(io::Error::last_os_error());
1145+ Ok(RawModeGuard { fd, original })
1148+struct RawModeGuard {
1150+ original: libc::termios,
1153+impl Drop for RawModeGuard {
1154+ fn drop(&mut self) {
1156+ libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
1161+// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1162+pub fn ensure_signed() {
1163+ let exe = std::env::current_exe().expect("failed to get current exe path");
1164+ let exe_str = exe.to_str().expect("exe path not valid utf-8");
1166+ let has_required_entitlements = {
1167+ let output = Command::new("codesign")
1168+ .args(["-d", "--entitlements", "-", "--xml", exe.to_str().unwrap()])
1172+ Ok(o) if o.status.success() => {
1173+ let stdout = String::from_utf8_lossy(&o.stdout);
1174+ stdout.contains("com.apple.security.virtualization")
1180+ if has_required_entitlements {
1184+ const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1185+ let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1186+ std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1188+ let status = Command::new("codesign")
1194+ entitlements_path.to_str().unwrap(),
1199+ let _ = std::fs::remove_file(&entitlements_path);
1202+ Ok(s) if s.success() => {
1203+ let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1204+ eprintln!("failed to re-exec after signing: {err}");
1205+ std::process::exit(1);
1208+ eprintln!("codesign failed with status: {s}");
1209+ std::process::exit(1);
1212+ eprintln!("failed to run codesign: {e}");
1213+ std::process::exit(1);