@@ -1,11 +1,7 @@
1−mod session_manager;
1+use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2−
2+use anyhow::{Context, Error, Result, bail};
3−pub use session_manager::{SessionError, SessionManager, SessionRecord};
9 io::{self, Write},
5 io::{self, Write},
11 fd::RawFd,
7 fd::RawFd,
@@ -19,6 +15,7 @@ use std::{
19 process::{Command, Stdio},
15 process::{Command, Stdio},
21 Arc, Condvar, Mutex,
17 Arc, Condvar, Mutex,
18+ atomic::{AtomicBool, Ordering},
22 mpsc::{self, Receiver, Sender},
19 mpsc::{self, Receiver, Sender},
@@ -26,8 +23,8 @@ use std::{
28use block2::RcBlock;
25use block2::RcBlock;
26+use bytesize::ByteSize;
29use dispatch2::DispatchQueue;
27use dispatch2::DispatchQueue;
30−use lexopt::prelude::*;
31use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
32use objc2_foundation::*;
29use objc2_foundation::*;
33use objc2_virtualization::*;
30use objc2_virtualization::*;
@@ -36,39 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
36const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
33const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
37const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
38const SHARED_DIRECTORIES_TAG: &str = "shared";
35const SHARED_DIRECTORIES_TAG: &str = "shared";
36+pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
40−const BYTES_PER_MB: u64 = 1024 * 1024;
41−const DEFAULT_CPU_COUNT: usize = 2;
42−const DEFAULT_RAM_MB: u64 = 2048;
43−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
44const START_TIMEOUT: Duration = Duration::from_secs(60);
38const START_TIMEOUT: Duration = Duration::from_secs(60);
45−const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
46const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
39const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
47const PROVISION_SCRIPT: &str = include_str!("provision.sh");
43const PROVISION_SCRIPT: &str = include_str!("provision.sh");
44+const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
45+const DEFAULT_RAW_NAME: &str = "default.raw";
46+const INSTANCE_RAW_NAME: &str = "instance.raw";
47+const BASE_DISK_RAW_NAME: &str = "disk.raw";
49#[derive(Clone)]
49#[derive(Clone)]
50−enum LoginAction {
50+pub enum LoginAction {
51− Expect { text: String, timeout: Duration },
53+ timeout: Duration,
52 Send(String),
60 Send(String),
53− Script { path: PathBuf, index: usize },
62+use crate::config::BoxConfig;
55use LoginAction::*;
63use LoginAction::*;
57#[derive(Clone)]
65#[derive(Clone)]
58−struct DirectoryShare {
66+pub struct DirectoryShare {
59 host: PathBuf,
67 host: PathBuf,
60 guest: PathBuf,
68 guest: PathBuf,
61 read_only: bool,
69 read_only: bool,
64impl DirectoryShare {
72impl DirectoryShare {
67− mut guest: PathBuf,
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
69− ) -> Result<Self, Box<dyn std::error::Error>> {
70 if !host.exists() {
74 if !host.exists() {
71− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
73 if !guest.is_absolute() {
77 if !guest.is_absolute() {
74 guest = PathBuf::from("/root").join(guest);
78 guest = PathBuf::from("/root").join(guest);
@@ -80,23 +84,22 @@ impl DirectoryShare {
83− fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
87+ pub fn from_mount_spec(spec: &str) -> Result<Self> {
84 let parts: Vec<&str> = spec.split(':').collect();
88 let parts: Vec<&str> = spec.split(':').collect();
85 if parts.len() < 2 || parts.len() > 3 {
89 if parts.len() < 2 || parts.len() > 3 {
86− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
88− let host = PathBuf::from(parts[0]);
92+ let host = expand_tilde_path(parts[0]);
89 let guest = PathBuf::from(parts[1]);
93 let guest = PathBuf::from(parts[1]);
90 let read_only = if parts.len() == 3 {
94 let read_only = if parts.len() == 3 {
91 match parts[2] {
95 match parts[2] {
92 "read-only" => true,
96 "read-only" => true,
93 "read-write" => false,
97 "read-write" => false,
95− return Err(format!(
99+ bail!(format!(
96 "Invalid mount mode '{}'; expected read-only or read-write",
100 "Invalid mount mode '{}'; expected read-only or read-write",
@@ -119,47 +122,58 @@ impl DirectoryShare {
122−fn main() -> Result<(), Box<dyn std::error::Error>> {
125+fn expand_tilde_path(value: &str) -> PathBuf {
123− let args = parse_cli()?;
126+ if let Some(stripped) = value.strip_prefix("~/") {
124−
127+ if let Ok(home) = env::var("HOME") {
125− if args.version {
128+ return PathBuf::from(home).join(stripped);
126− println!("Vibe");
129+ }
127− println!("https://github.com/lynaghk/vibe/");
130+ } else if value == "~"
128− println!("Git SHA: {}", env!("GIT_SHA"));
131+ && let Ok(home) = env::var("HOME")
129− std::process::exit(0);
132+ {
133+ return PathBuf::from(home);
135+ PathBuf::from(value)
139+ pub cpu_count: usize,
141+ pub disk_bytes: u64,
142+ pub no_default_mounts: bool,
143+ pub mounts: Vec<String>,
146+type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
132− if args.help {
148+fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
133− println!(
149+ if let Some(status) = status {
134− "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
150+ status(message);
136−vibe [OPTIONS] [disk-image.raw]
140− --help Print this help message.
141− --version Print the version (commit SHA).
142− --no-default-mounts Disable all default mounts.
143− --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
144− Defaults to read-write.
145− Errors if host-path does not exist.
146− --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
147− --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
148− --script <path/to/script.sh> Run script in VM.
149− --send <some-command> Type `some-command` followed by newline into the VM.
150− --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
151− If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
154− std::process::exit(0);
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
156+ F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
158+ run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
161+pub fn run_with_args_and_extras<F>(
164+ extra_login_actions: Vec<LoginAction>,
165+ extra_directory_shares: Vec<DirectoryShare>,
166+ status: Option<&StatusEmitter<'_>>,
169+ F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
157 ensure_signed();
171 ensure_signed();
159 let project_root = env::current_dir()?;
173 let project_root = env::current_dir()?;
160 let project_name = project_root
174 let project_name = project_root
161 .file_name()
175 .file_name()
162− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
163 .to_string_lossy()
177 .to_string_lossy()
164 .into_owned();
178 .into_owned();
@@ -167,10 +181,14 @@ Options
167 let cache_home = env::var("XDG_CACHE_HOME")
181 let cache_home = env::var("XDG_CACHE_HOME")
168 .map(PathBuf::from)
182 .map(PathBuf::from)
169 .unwrap_or_else(|_| home.join(".cache"));
183 .unwrap_or_else(|_| home.join(".cache"));
170− let cache_dir = cache_home.join("vibe");
184+ let cache_dir = cache_home.join(GLOBAL_CACHE_DIR_NAME);
171 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
173− let instance_dir = project_root.join(".vibe");
187+ let instance_dir = project_root.join(INSTANCE_DIR_NAME);
188+ fs::create_dir_all(&instance_dir)?;
189+ emit_status(status, "preparing VM image...");
190+ tracing::info!("preparing VM image...");
191+ let provision_log = instance_dir.join("provision.log");
175 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
176 let base_compressed = cache_dir.join(basename_compressed);
194 let base_compressed = cache_dir.join(basename_compressed);
@@ -179,8 +197,8 @@ Options
179 basename_compressed.trim_end_matches(".tar.xz")
197 basename_compressed.trim_end_matches(".tar.xz")
182− let default_raw = cache_dir.join("default.raw");
200+ let default_raw = cache_dir.join(DEFAULT_RAW_NAME);
183− let instance_raw = instance_dir.join("instance.raw");
201+ let instance_raw = instance_dir.join(INSTANCE_RAW_NAME);
185 // Prepare system-wide directories
203 // Prepare system-wide directories
186 fs::create_dir_all(&cache_dir)?;
204 fs::create_dir_all(&cache_dir)?;
@@ -189,28 +207,31 @@ Options
189 let mise_directory_share =
207 let mise_directory_share =
190 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
208 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
192− let disk_path = if let Some(path) = args.disk {
194− return Err(format!("Disk image does not exist: {}", path.display()).into());
198− ensure_default_image(
210+ ensure_default_image(
199− &base_raw,
211+ &base_raw,
200− &base_compressed,
212+ &base_compressed,
201− &default_raw,
213+ &default_raw,
202− std::slice::from_ref(&mise_directory_share),
214+ std::slice::from_ref(&mise_directory_share),
215+ Some(&provision_log),
204− ensure_instance_disk(&instance_raw, &default_raw)?;
218+ let _ = ensure_instance_disk(
221+ ByteSize(args.disk_bytes),
224+ let base_size = fs::metadata(&default_raw)?.len();
206− instance_raw
225+ let instance_size = fs::metadata(&instance_raw)?.len();
207− };
226+ let needs_resize = instance_size > base_size;
227+ let disk_path = instance_raw;
209 let mut login_actions = Vec::new();
229 let mut login_actions = Vec::new();
210 let mut directory_shares = Vec::new();
230 let mut directory_shares = Vec::new();
212 if !args.no_default_mounts {
232 if !args.no_default_mounts {
233+ let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
213− login_actions.push(Send(format!("cd {project_name}")));
234+ login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
215 // 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.
236 // 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.
216 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
237 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -218,170 +239,55 @@ Options
218 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
239 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
221− directory_shares.push(
222− DirectoryShare::new(
224− PathBuf::from("/root/").join(project_name),
227− .expect("Project directory must exist"),
230 directory_shares.push(mise_directory_share);
242 directory_shares.push(mise_directory_share);
232− // Add default shares, if they exist
234− DirectoryShare::new(home.join(".m2"), "/root/.m2".into(), false),
235− DirectoryShare::new(
236− home.join(".cargo/registry"),
237− "/root/.cargo/registry".into(),
240− DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
241− DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
246− directory_shares.push(share)
245+ directory_shares.extend(extra_directory_shares);
250 for spec in &args.mounts {
247 for spec in &args.mounts {
251 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
248 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
253+ login_actions.push(Send(resize_cmd));
254 if let Some(motd_action) = motd_login_action(&directory_shares) {
256 if let Some(motd_action) = motd_login_action(&directory_shares) {
255 login_actions.push(motd_action);
257 login_actions.push(motd_action);
258− // Any user-provided login actions must come after our system ones
259− login_actions.extend(args.login_actions);
260+ login_actions.extend(extra_login_actions);
261− run_vm(
262+ run_vm_with_io(
262 &disk_path,
263 &disk_path,
263 &login_actions,
264 &login_actions,
264 &directory_shares[..],
265 &directory_shares[..],
265 args.cpu_count,
266 args.cpu_count,
266 args.ram_bytes,
267 args.ram_bytes,
271− disk: Option<PathBuf>,
274− no_default_mounts: bool,
275− mounts: Vec<String>,
276− login_actions: Vec<LoginAction>,
281−fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
282− fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
285− .map_err(|_| format!("{flag} expects valid UTF-8").into())
288− let mut parser = lexopt::Parser::from_env();
289− let mut disk = None;
290− let mut version = false;
291− let mut help = false;
292− let mut no_default_mounts = false;
293− let mut mounts = Vec::new();
294− let mut login_actions = Vec::new();
295− let mut script_index = 0;
296− let mut cpu_count = DEFAULT_CPU_COUNT;
297− let mut ram_bytes = DEFAULT_RAM_BYTES;
299− while let Some(arg) = parser.next()? {
301− Long("version") => version = true,
302− Long("help") | Short('h') => help = true,
303− Long("no-default-mounts") => no_default_mounts = true,
305− let value = os_to_string(parser.value()?, "--cpus")?.parse()?;
307− return Err("--cpus must be >= 1".into());
312− let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?;
314− return Err("--ram must be >= 1".into());
316− ram_bytes = value * BYTES_PER_MB;
319− mounts.push(os_to_string(parser.value()?, "--mount")?);
322− login_actions.push(Script {
323− path: os_to_string(parser.value()?, "--script")?.into(),
324− index: script_index,
329− login_actions.push(Send(os_to_string(parser.value()?, "--send")?));
332− let text = os_to_string(parser.value()?, "--expect")?;
333− let timeout = match parser.optional_value() {
334− Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?),
335− None => DEFAULT_EXPECT_TIMEOUT,
337− login_actions.push(Expect { text, timeout });
341− return Err("Only one disk path may be provided".into());
343− disk = Some(PathBuf::from(value));
345− _ => return Err(arg.unexpected().into()),
361−fn script_command_from_path(
364−) -> Result<String, Box<dyn std::error::Error>> {
365− let script = fs::read_to_string(path)
366− .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
367− let label = format!("{}_{}", index, path.file_name().unwrap().display());
368− script_command_from_content(&label, &script)
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
371−fn script_command_from_content(
374−) -> Result<String, Box<dyn std::error::Error>> {
375 let marker = "VIBE_SCRIPT_EOF";
274 let marker = "VIBE_SCRIPT_EOF";
376 let guest_dir = "/tmp/vibe-scripts";
275 let guest_dir = "/tmp/vibe-scripts";
377 let guest_path = format!("{guest_dir}/{label}.sh");
276 let guest_path = format!("{guest_dir}/{label}.sh");
277+ let script_body = match script.split_once('\n') {
278+ Some((first, rest)) if first.starts_with("#!") => rest,
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
378 let command = format!(
284 let command = format!(
379− "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
285+ "mkdir -p {guest_dir}\ncat >{guest_path} <<'{marker}'\n{wrapped_script}\n{marker}\nchmod +x {guest_path}\n{guest_path}"
381 if script.contains(marker) {
287 if script.contains(marker) {
382− return Err(
288+ bail!(format!(
383− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
386 Ok(command)
292 Ok(command)
@@ -415,18 +321,6 @@ fn motd_login_action(directory_shares: &[DirectoryShare]) -> Option<LoginAction>
417 let mut output = String::new();
323 let mut output = String::new();
420−░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
421−░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
422− ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
423− ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░
424− ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
425− ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░
426− ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░
430 output.push_str(&format!(
324 output.push_str(&format!(
431 "{host_header:<host_width$} {guest_header:<guest_width$} {mode_header}\n",
325 "{host_header:<host_width$} {guest_header:<guest_width$} {mode_header}\n",
432 host_width = host_width
326 host_width = host_width
@@ -457,6 +351,12 @@ enum WaitResult {
354+#[derive(PartialEq, Eq)]
460pub enum VmInput {
360pub enum VmInput {
461 Bytes(Vec<u8>),
361 Bytes(Vec<u8>),
462 Shutdown,
362 Shutdown,
@@ -464,6 +364,7 @@ pub enum VmInput {
465enum VmOutput {
365enum VmOutput {
466 LoginActionTimeout { action: String, timeout: Duration },
366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
469#[derive(Default)]
370#[derive(Default)]
@@ -500,20 +401,100 @@ impl OutputMonitor {
500 WaitResult::Found
401 WaitResult::Found
405+ fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
406+ let mut found: Option<usize> = None;
407+ let (_unused, timeout_result) = self
409+ .wait_timeout_while(self.buffer.lock().unwrap(), timeout, |buf| {
410+ if let Some((pos, idx, len)) = find_any(buf, needles) {
411+ *buf = buf[(pos + len)..].to_string();
420+ if timeout_result.timed_out() {
421+ WaitAnyResult::Timeout
423+ WaitAnyResult::Found(found.unwrap_or(0))
428+fn find_any(buf: &str, needles: &[&str]) -> Option<(usize, usize, usize)> {
429+ let mut best: Option<(usize, usize, usize)> = None; // (pos, idx, len)
430+ for (idx, needle) in needles.iter().enumerate() {
431+ if let Some(pos) = buf.find(needle) {
432+ let candidate = (pos, idx, needle.len());
433+ if best.is_none_or(|b| candidate.0 < b.0) {
434+ best = Some(candidate);
442+pub struct IoControl {
443+ forward_input: AtomicBool,
444+ forward_output: AtomicBool,
445+ restore_terminal: AtomicBool,
449+ pub fn new() -> Arc<Self> {
451+ forward_input: AtomicBool::new(true),
452+ forward_output: AtomicBool::new(true),
453+ restore_terminal: AtomicBool::new(false),
457+ pub fn set_forward_input(&self, enabled: bool) {
458+ self.forward_input.store(enabled, Ordering::SeqCst);
461+ pub fn set_forward_output(&self, enabled: bool) {
462+ self.forward_output.store(enabled, Ordering::SeqCst);
465+ pub fn request_terminal_restore(&self) {
466+ self.restore_terminal.store(true, Ordering::SeqCst);
469+ fn forward_input(&self) -> bool {
470+ self.forward_input.load(Ordering::SeqCst)
473+ fn forward_output(&self) -> bool {
474+ self.forward_output.load(Ordering::SeqCst)
477+ fn take_restore_terminal(&self) -> bool {
478+ self.restore_terminal
479+ .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
505fn ensure_base_image(
484fn ensure_base_image(
506 base_raw: &Path,
485 base_raw: &Path,
507 base_compressed: &Path,
486 base_compressed: &Path,
508−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
509 if base_raw.exists() {
489 if base_raw.exists() {
510 return Ok(());
490 return Ok(());
513 if !base_compressed.exists()
493 if !base_compressed.exists()
514− || std::fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
494+ || fs::metadata(base_compressed).map(|m| m.len())? < DEBIAN_COMPRESSED_SIZE_BYTES
516− println!("Downloading base image...");
496+ emit_status(status, "downloading base image...");
497+ tracing::info!("downloading base image");
517 let status = Command::new("curl")
498 let status = Command::new("curl")
519 "--continue-at",
500 "--continue-at",
@@ -527,12 +508,14 @@ fn ensure_base_image(
528 .status()?;
509 .status()?;
529 if !status.success() {
510 if !status.success() {
530− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
534 // Check SHA
515 // Check SHA
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
536 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
538 let mut child = Command::new("/usr/bin/shasum")
521 let mut child = Command::new("/usr/bin/shasum")
@@ -550,18 +533,25 @@ fn ensure_base_image(
551 let status = child.wait().expect("failed to wait on child");
534 let status = child.wait().expect("failed to wait on child");
552 if !status.success() {
535 if !status.success() {
553− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
557− println!("Decompressing base image...");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
558 let status = Command::new("tar")
544 let status = Command::new("tar")
559− .args(["-xOf", &base_compressed.to_string_lossy(), "disk.raw"])
547+ &base_compressed.to_string_lossy(),
560− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
561 .status()?;
551 .status()?;
563 if !status.success() {
553 if !status.success() {
564− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
@@ -572,24 +562,56 @@ fn ensure_default_image(
572 base_compressed: &Path,
562 base_compressed: &Path,
573 default_raw: &Path,
563 default_raw: &Path,
574 directory_shares: &[DirectoryShare],
564 directory_shares: &[DirectoryShare],
575−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
576 if default_raw.exists() {
568 if default_raw.exists() {
577 return Ok(());
569 return Ok(());
580− ensure_base_image(base_raw, base_compressed)?;
572+ ensure_base_image(base_raw, base_compressed, status)?;
582− println!("Configuring base image...");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
583 fs::copy(base_raw, default_raw)?;
576 fs::copy(base_raw, default_raw)?;
585 let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
578 let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
586− run_vm(
579+ let provision_actions = [
588− &[Send(provision_command)],
580+ Send(provision_command),
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
596+ move |output_monitor, vm_output_fd, vm_input_fd| {
597+ spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
589− directory_shares,
604+ directory_shares,
590− DEFAULT_CPU_COUNT,
605+ BoxConfig::default().cpu_count,
591− DEFAULT_RAM_BYTES,
606+ BoxConfig::default().ram_size.as_u64(),
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
@@ -597,15 +619,47 @@ fn ensure_default_image(
597fn ensure_instance_disk(
619fn ensure_instance_disk(
598 instance_raw: &Path,
620 instance_raw: &Path,
599 template_raw: &Path,
621 template_raw: &Path,
600−) -> Result<(), Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
601 if instance_raw.exists() {
625 if instance_raw.exists() {
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
627+ if current_size != target_bytes {
628+ let current_gb = current_size;
629+ let target_gb = target_bytes;
631+ "instance disk size does not match config (current {}, config {}); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
602− return Ok(());
636+ return Ok(false);
605− println!("Creating instance disk from {}...", template_raw.display());
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
640+ if target_bytes < template_size {
642+ "Requested disk size {} is smaller than base image size {}",
643+ target_bytes, template_size
646+ let target_size = target_bytes;
647+ let needs_resize = target_size > template_size;
649+ emit_status(status, "creating instance disk...");
650+ tracing::info!("creating instance disk...");
651+ tracing::info!(path = %template_raw.display(), "creating instance disk");
606− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
653+ if target_size == template_size {
607− fs::copy(template_raw, instance_raw)?;
654+ fs::copy(template_raw, instance_raw)?;
608− Ok(())
655+ return Ok(needs_resize);
658+ let mut dst = fs::File::create(instance_raw)?;
659+ dst.set_len(target_size.as_u64())?;
660+ let mut src = fs::File::open(template_raw)?;
661+ std::io::copy(&mut src, &mut dst)?;
611pub struct IoContext {
665pub struct IoContext {
@@ -621,10 +675,16 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
621 (read_stream.into(), write_stream.into())
675 (read_stream.into(), write_stream.into())
624−pub fn spawn_vm_io(
678+pub fn spawn_vm_io_with_hooks<
679+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
680+ G: FnMut(&[u8]) + std::marker::Send + 'static,
625 output_monitor: Arc<OutputMonitor>,
682 output_monitor: Arc<OutputMonitor>,
626 vm_output_fd: OwnedFd,
683 vm_output_fd: OwnedFd,
627 vm_input_fd: OwnedFd,
684 vm_input_fd: OwnedFd,
685+ io_control: Arc<IoControl>,
628) -> IoContext {
688) -> IoContext {
629 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
@@ -640,7 +700,12 @@ pub fn spawn_vm_io(
643− fn poll_with_wakeup<'a>(main_fd: RawFd, wakeup_fd: RawFd, buf: &'a mut [u8]) -> PollResult<'a> {
703+ fn poll_with_wakeup<'a>(
708+ ) -> PollResult<'a> {
644 let mut fds = [
709 let mut fds = [
645 libc::pollfd {
710 libc::pollfd {
646 fd: main_fd,
711 fd: main_fd,
@@ -654,8 +719,12 @@ pub fn spawn_vm_io(
657− let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) };
722+ let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, timeout_ms) };
724+ PollResult::Spurious
658− if ret <= 0 || fds[1].revents & libc::POLLIN != 0 {
727+ } else if fds[1].revents & libc::POLLIN != 0 {
659 PollResult::Shutdown
728 PollResult::Shutdown
660 } else if fds[0].revents & libc::POLLIN != 0 {
729 } else if fds[0].revents & libc::POLLIN != 0 {
661 let n = unsafe { libc::read(main_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
730 let n = unsafe { libc::read(main_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
@@ -671,23 +740,68 @@ pub fn spawn_vm_io(
743+ fn poll_wakeup_only(wakeup_fd: RawFd, timeout_ms: i32) -> bool {
744+ let mut fds = [libc::pollfd {
746+ events: libc::POLLIN,
750+ let ret = unsafe { libc::poll(fds.as_mut_ptr(), 1, timeout_ms) };
751+ ret > 0 && fds[0].revents & libc::POLLIN != 0
674 // Copies from stdin to the VM; also polls wakeup_read to exit the thread when it's time to shutdown.
754 // Copies from stdin to the VM; also polls wakeup_read to exit the thread when it's time to shutdown.
675 let stdin_thread = thread::spawn({
755 let stdin_thread = thread::spawn({
676 let input_tx = input_tx.clone();
756 let input_tx = input_tx.clone();
677 let raw_guard = raw_guard.clone();
757 let raw_guard = raw_guard.clone();
678 let wakeup_read = wakeup_read.try_clone().unwrap();
758 let wakeup_read = wakeup_read.try_clone().unwrap();
759+ let io_control = io_control.clone();
680 move || {
761 move || {
681 let mut buf = [0u8; 1024];
762 let mut buf = [0u8; 1024];
763+ let mut pending_command: Vec<u8> = Vec::new();
764+ let mut command_mode = false;
766+ if !io_control.forward_input() {
767+ if poll_wakeup_only(wakeup_read.as_raw_fd(), 100) {
683− match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
773+ match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf, -1) {
684 PollResult::Shutdown | PollResult::Error => break,
774 PollResult::Shutdown | PollResult::Error => break,
685 PollResult::Spurious => continue,
775 PollResult::Spurious => continue,
686 PollResult::Ready(bytes) => {
776 PollResult::Ready(bytes) => {
777+ let mut send_buf: Vec<u8> = Vec::new();
779+ if pending_command.is_empty() && !command_mode && b == b':' {
780+ command_mode = true;
784+ pending_command.push(b);
789+ if b == b'\n' && command_mode {
790+ let line = String::from_utf8_lossy(&pending_command);
791+ let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
792+ let consumed = on_line(trimmed);
794+ send_buf.extend_from_slice(&pending_command);
796+ pending_command.clear();
797+ command_mode = false;
687 if raw_guard.lock().unwrap().is_none() {
800 if raw_guard.lock().unwrap().is_none() {
688 continue;
801 continue;
690− if input_tx.send(VmInput::Bytes(bytes.to_vec())).is_err() {
803+ if !send_buf.is_empty() && input_tx.send(VmInput::Bytes(send_buf)).is_err()
@@ -700,28 +814,40 @@ pub fn spawn_vm_io(
700 let stdout_thread = thread::spawn({
814 let stdout_thread = thread::spawn({
701 let raw_guard = raw_guard.clone();
815 let raw_guard = raw_guard.clone();
702 let wakeup_read = wakeup_read.try_clone().unwrap();
816 let wakeup_read = wakeup_read.try_clone().unwrap();
817+ let io_control = io_control.clone();
704 move || {
819 move || {
705− let mut stdout = std::io::stdout().lock();
706 let mut buf = [0u8; 1024];
820 let mut buf = [0u8; 1024];
822+ if io_control.take_restore_terminal() {
823+ let mut guard = raw_guard.lock().unwrap();
708− match poll_with_wakeup(vm_output_fd.as_raw_fd(), wakeup_read.as_raw_fd(), &mut buf)
826+ match poll_with_wakeup(
827+ vm_output_fd.as_raw_fd(),
828+ wakeup_read.as_raw_fd(),
710 PollResult::Shutdown | PollResult::Error => break,
832 PollResult::Shutdown | PollResult::Error => break,
711 PollResult::Spurious => continue,
833 PollResult::Spurious => continue,
712 PollResult::Ready(bytes) => {
834 PollResult::Ready(bytes) => {
835+ if io_control.forward_output() {
713− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
714− if raw_guard.lock().unwrap().is_none()
837+ if raw_guard.lock().unwrap().is_none()
715− && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
838+ && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
717− *raw_guard.lock().unwrap() = Some(guard);
840+ *raw_guard.lock().unwrap() = Some(guard);
843+ let mut stdout = io::stdout().lock();
720− if stdout.write_all(bytes).is_err() {
844+ if stdout.write_all(bytes).is_err() {
847+ let _ = stdout.flush();
723− let _ = stdout.flush();
724 output_monitor.push(bytes);
849 output_monitor.push(bytes);
@@ -730,7 +856,7 @@ pub fn spawn_vm_io(
731 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
732 let mux_thread = thread::spawn(move || {
858 let mux_thread = thread::spawn(move || {
733− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
735 match input_rx.recv() {
861 match input_rx.recv() {
736 Ok(VmInput::Bytes(data)) => {
862 Ok(VmInput::Bytes(data)) => {
@@ -753,6 +879,63 @@ pub fn spawn_vm_io(
882+pub fn spawn_vm_io_with_line_handler<F>(
883+ output_monitor: Arc<OutputMonitor>,
884+ vm_output_fd: OwnedFd,
885+ vm_input_fd: OwnedFd,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
891+ spawn_vm_io_with_hooks(
902+ output_monitor: Arc<OutputMonitor>,
903+ vm_output_fd: OwnedFd,
904+ vm_input_fd: OwnedFd,
906+ spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
909+fn spawn_vm_io_with_log(
910+ output_monitor: Arc<OutputMonitor>,
911+ vm_output_fd: OwnedFd,
912+ vm_input_fd: OwnedFd,
915+ let log_file = fs::OpenOptions::new()
921+ .map(|file| Arc::new(Mutex::new(file)));
923+ spawn_vm_io_with_hooks(
930+ if let Some(log) = &log_file
931+ && let Ok(mut file) = log.lock()
933+ let _ = file.write_all(bytes);
756impl IoContext {
939impl IoContext {
757 pub fn shutdown(self) {
940 pub fn shutdown(self) {
758 let _ = self.input_tx.send(VmInput::Shutdown);
941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -770,7 +953,7 @@ fn create_vm_configuration(
770 vm_writes_to_fd: OwnedFd,
953 vm_writes_to_fd: OwnedFd,
771 cpu_count: usize,
954 cpu_count: usize,
772 ram_bytes: u64,
955 ram_bytes: u64,
773−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
775 let platform =
958 let platform =
776 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -804,7 +987,7 @@ fn create_vm_configuration(
805 VZDiskImageCachingMode::Automatic,
988 VZDiskImageCachingMode::Automatic,
806 VZDiskImageSynchronizationMode::Full,
989 VZDiskImageSynchronizationMode::Full,
809 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
810 VZVirtioBlockDeviceConfiguration::alloc(),
993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -891,7 +1074,7 @@ fn create_vm_configuration(
891 // Validate
1074 // Validate
892 config.validateWithError().map_err(|e| {
1075 config.validateWithError().map_err(|e| {
893 io::Error::other(format!(
1076 io::Error::other(format!(
894− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
895 e.localizedDescription()
1078 e.localizedDescription()
@@ -900,9 +1083,9 @@ fn create_vm_configuration(
903−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
904 unsafe {
1087 unsafe {
905− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
906 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
1089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
907 let url = nsurl_from_path(&temp_path)?;
1090 let url = nsurl_from_path(&temp_path)?;
908 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
1091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -918,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
918fn spawn_login_actions_thread(
1101fn spawn_login_actions_thread(
919 login_actions: Vec<LoginAction>,
1102 login_actions: Vec<LoginAction>,
920 output_monitor: Arc<OutputMonitor>,
1103 output_monitor: Arc<OutputMonitor>,
921− input_tx: mpsc::Sender<VmInput>,
1104+ input_tx: Sender<VmInput>,
922− vm_output_tx: mpsc::Sender<VmOutput>,
1105+ vm_output_tx: Sender<VmOutput>,
923) -> thread::JoinHandle<()> {
1106) -> thread::JoinHandle<()> {
924 thread::spawn(move || {
1107 thread::spawn(move || {
925 for a in login_actions {
1108 for a in login_actions {
@@ -933,34 +1116,48 @@ fn spawn_login_actions_thread(
1123+ } => match output_monitor.wait_for_any(&[&success, &failure], timeout) {
1124+ WaitAnyResult::Found(0) => {}
1125+ WaitAnyResult::Found(_) => {
1126+ let _ = vm_output_tx.send(VmOutput::LoginActionFailed {
1127+ action: format!("expect '{}'", success),
1128+ reason: format!("saw failure marker '{}'", failure),
1132+ WaitAnyResult::Timeout => {
1133+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
1134+ action: format!("expect '{}'", success),
936 Send(mut text) => {
1140 Send(mut text) => {
937 text.push('\n'); // Type the newline so the command is actually submitted.
1141 text.push('\n'); // Type the newline so the command is actually submitted.
938 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
940− Script { path, index } => {
941− let command = match script_command_from_path(&path, index) {
942− Ok(command) => command,
948− let mut text = command;
950− input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
957−fn run_vm(
1149+fn run_vm_with_io<F>(
958 disk_path: &Path,
1150 disk_path: &Path,
959 login_actions: &[LoginAction],
1151 login_actions: &[LoginAction],
960 directory_shares: &[DirectoryShare],
1152 directory_shares: &[DirectoryShare],
961 cpu_count: usize,
1153 cpu_count: usize,
962 ram_bytes: u64,
1154 ram_bytes: u64,
963−) -> Result<(), Box<dyn std::error::Error>> {
1155+ status: Option<&StatusEmitter<'_>>,
1159+ F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
964 let (vm_reads_from, we_write_to) = create_pipe();
1161 let (vm_reads_from, we_write_to) = create_pipe();
965 let (we_read_from, vm_writes_to) = create_pipe();
1162 let (we_read_from, vm_writes_to) = create_pipe();
@@ -1004,24 +1201,26 @@ fn run_vm(
1005 match rx.try_recv() {
1202 match rx.try_recv() {
1006 Ok(result) => {
1203 Ok(result) => {
1007− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
1010 Err(mpsc::TryRecvError::Empty) => continue,
1207 Err(mpsc::TryRecvError::Empty) => continue,
1011 Err(mpsc::TryRecvError::Disconnected) => {
1208 Err(mpsc::TryRecvError::Disconnected) => {
1012− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
1017 if Instant::now() >= start_deadline {
1214 if Instant::now() >= start_deadline {
1018− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
1021− println!("VM booting...");
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1220+ tracing::info!("vm booting");
1023 let output_monitor = Arc::new(OutputMonitor::default());
1222 let output_monitor = Arc::new(OutputMonitor::default());
1024− let io_ctx = spawn_vm_io(output_monitor.clone(), we_read_from, we_write_to);
1223+ let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
1026 let mut all_login_actions = vec![
1225 let mut all_login_actions = vec![
1027 Expect {
1226 Expect {
@@ -1063,7 +1262,7 @@ fn run_vm(
1065 let mut last_state = None;
1264 let mut last_state = None;
1066− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
1068 unsafe {
1267 unsafe {
1069 NSRunLoop::mainRunLoop().runMode_beforeDate(
1268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1082,12 +1281,28 @@ fn run_vm(
1082 exit_result = Err(format!(
1281 exit_result = Err(format!(
1083 "Login action ({}) timed out after {:?}; shutting down.",
1282 "Login action ({}) timed out after {:?}; shutting down.",
1084 action, timeout
1283 action, timeout
1286+ if vm.canRequestStop() {
1287+ if let Err(err) = vm.requestStopWithError() {
1288+ tracing::error!(error = ?err, "failed to request VM stop");
1290+ } else if vm.canStop() {
1291+ let handler = RcBlock::new(|_error: *mut NSError| {});
1292+ vm.stopWithCompletionHandler(&handler);
1297+ Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298+ exit_result = Err(format!(
1299+ "Login action ({}) failed: {}; shutting down.",
1087 unsafe {
1302 unsafe {
1088 if vm.canRequestStop() {
1303 if vm.canRequestStop() {
1089 if let Err(err) = vm.requestStopWithError() {
1304 if let Err(err) = vm.requestStopWithError() {
1090− eprintln!("Failed to request VM stop: {:?}", err);
1305+ tracing::error!(error = ?err, "failed to request VM stop");
1092 } else if vm.canStop() {
1307 } else if vm.canStop() {
1093 let handler = RcBlock::new(|_error: *mut NSError| {});
1308 let handler = RcBlock::new(|_error: *mut NSError| {});
@@ -1099,7 +1314,7 @@ fn run_vm(
1099 Err(mpsc::TryRecvError::Empty) => {}
1314 Err(mpsc::TryRecvError::Empty) => {}
1100 Err(mpsc::TryRecvError::Disconnected) => {}
1315 Err(mpsc::TryRecvError::Disconnected) => {}
1102− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
1103 //eprintln!("VM stopped with state: {:?}", state);
1318 //eprintln!("VM stopped with state: {:?}", state);
@@ -1109,10 +1324,29 @@ fn run_vm(
1110 io_ctx.shutdown();
1325 io_ctx.shutdown();
1112− exit_result
1327+ exit_result.map_err(Error::msg)
1332+ login_actions: &[LoginAction],
1333+ directory_shares: &[DirectoryShare],
1336+ status: Option<&StatusEmitter<'_>>,
1115−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
1116 let abs_path = if path.is_absolute() {
1350 let abs_path = if path.is_absolute() {
1117 path.to_path_buf()
1351 path.to_path_buf()
1118 } else {
1352 } else {
@@ -1121,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
1121 let ns_path = NSString::from_str(
1355 let ns_path = NSString::from_str(
1122 abs_path
1356 abs_path
1123 .to_str()
1357 .to_str()
1124− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
1126 Ok(NSURL::fileURLWithPath(&ns_path))
1360 Ok(NSURL::fileURLWithPath(&ns_path))
@@ -1136,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1136 let original = attributes;
1370 let original = attributes;
1138 // Disable translation of carriage return to newline on input
1372 // Disable translation of carriage return to newline on input
1139− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
1140 // Disable canonical mode (line buffering), echo, and signal generation
1374 // Disable canonical mode (line buffering), echo, and signal generation
1141 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1142 attributes.c_cc[libc::VMIN] = 0;
1376 attributes.c_cc[libc::VMIN] = 0;
@@ -1164,7 +1398,10 @@ impl Drop for RawModeGuard {
1165// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
1166pub fn ensure_signed() {
1400pub fn ensure_signed() {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1167− let exe = std::env::current_exe().expect("failed to get current exe path");
1404+ let exe = env::current_exe().expect("failed to get current exe path");
1168 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
1170 let has_required_entitlements = {
1407 let has_required_entitlements = {
@@ -1186,10 +1423,10 @@ pub fn ensure_signed() {
1188 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1189− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1190− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1192− let status = Command::new("codesign")
1429+ let output = Command::new("codesign")
1194 "--sign",
1431 "--sign",
@@ -1198,22 +1435,27 @@ pub fn ensure_signed() {
1198 entitlements_path.to_str().unwrap(),
1435 entitlements_path.to_str().unwrap(),
1199 exe_str,
1436 exe_str,
1201− .status();
1438+ .output();
1203− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
1205− match status {
1442+ match output {
1206− Ok(s) if s.success() => {
1443+ Ok(o) if o.status.success() => {
1444+ let stderr = String::from_utf8_lossy(&o.stderr);
1445+ if !stderr.trim().is_empty() {
1446+ tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1207− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
1208− eprintln!("failed to re-exec after signing: {err}");
1449+ tracing::error!(error = %err, "failed to re-exec after signing");
1209 std::process::exit(1);
1450 std::process::exit(1);
1211− Ok(s) => {
1452+ Ok(o) => {
1212− eprintln!("codesign failed with status: {s}");
1453+ let stderr = String::from_utf8_lossy(&o.stderr);
1454+ tracing::error!(status = %o.status, codesign_stderr = %stderr.trim(), "codesign failed");
1213 std::process::exit(1);
1455 std::process::exit(1);
1215 Err(e) => {
1457 Err(e) => {
1216− eprintln!("failed to run codesign: {e}");
1458+ tracing::error!(error = %e, "failed to run codesign");
1217 std::process::exit(1);
1459 std::process::exit(1);