@@ -1,8 +1,7 @@
11use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
23use std::{
3− env,
4− ffi::OsString,
5− fs,
4+ env, fs,
65 io::{self, Write},
76 os::{
87 fd::RawFd,
@@ -24,8 +23,8 @@ use std::{
2423};
2524
2625use block2::RcBlock;
26+use bytesize::ByteSize;
2727use dispatch2::DispatchQueue;
28−use lexopt::prelude::*;
2928use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
3029use objc2_foundation::*;
3130use objc2_virtualization::*;
@@ -34,43 +33,46 @@ const DEBIAN_COMPRESSED_DISK_URL: &str = "https://cloud.debian.org/images/cloud/
3433const DEBIAN_COMPRESSED_SHA: &str = "6ab9be9e6834adc975268367f2f0235251671184345c34ee13031749fdfbf66fe4c3aafd949a2d98550426090e9ac645e79009c51eb0eefc984c15786570bb38";
3534const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
3635const SHARED_DIRECTORIES_TAG: &str = "shared";
36+pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
3737
38−const BYTES_PER_MB: u64 = 1024 * 1024;
39−const DEFAULT_CPU_COUNT: usize = 2;
40−const DEFAULT_RAM_MB: u64 = 2048;
41−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
4238const START_TIMEOUT: Duration = Duration::from_secs(60);
43−const DEFAULT_EXPECT_TIMEOUT: Duration = Duration::from_secs(30);
4439const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
40+const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
41+
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
4543const PROVISION_SCRIPT: &str = include_str!("provision.sh");
46−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
44+const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
4745const DEFAULT_RAW_NAME: &str = "default.raw";
4846const INSTANCE_RAW_NAME: &str = "instance.raw";
4947const BASE_DISK_RAW_NAME: &str = "disk.raw";
5048
5149#[derive(Clone)]
52−pub(crate) enum LoginAction {
53− Expect { text: String, timeout: Duration },
50+pub enum LoginAction {
51+ Expect {
52+ text: String,
53+ timeout: Duration,
54+ },
55+ ExpectEither {
56+ success: String,
57+ failure: String,
58+ timeout: Duration,
59+ },
5460 Send(String),
55− Script { path: PathBuf, index: usize },
5661}
62+use crate::config::BoxConfig;
5763use LoginAction::*;
5864
5965#[derive(Clone)]
60−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
6167 host: PathBuf,
6268 guest: PathBuf,
6369 read_only: bool,
6470}
6571
6672impl DirectoryShare {
67− pub(crate) fn new(
68− host: PathBuf,
69− mut guest: PathBuf,
70− read_only: bool,
71− ) -> Result<Self, Box<dyn std::error::Error>> {
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
7274 if !host.exists() {
73− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
7476 }
7577 if !guest.is_absolute() {
7678 guest = PathBuf::from("/root").join(guest);
@@ -82,23 +84,22 @@ impl DirectoryShare {
8284 })
8385 }
8486
85− pub(crate) fn from_mount_spec(spec: &str) -> Result<Self, Box<dyn std::error::Error>> {
87+ pub fn from_mount_spec(spec: &str) -> Result<Self> {
8688 let parts: Vec<&str> = spec.split(':').collect();
8789 if parts.len() < 2 || parts.len() > 3 {
88− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
8991 }
90− let host = PathBuf::from(parts[0]);
92+ let host = expand_tilde_path(parts[0]);
9193 let guest = PathBuf::from(parts[1]);
9294 let read_only = if parts.len() == 3 {
9395 match parts[2] {
9496 "read-only" => true,
9597 "read-write" => false,
9698 _ => {
97− return Err(format!(
99+ bail!(format!(
98100 "Invalid mount mode '{}'; expected read-only or read-write",
99101 parts[2]
100− )
101− .into());
102+ ));
102103 }
103104 }
104105 } else {
@@ -121,19 +122,49 @@ impl DirectoryShare {
121122 }
122123}
123124
124−pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
125+fn expand_tilde_path(value: &str) -> PathBuf {
126+ if let Some(stripped) = value.strip_prefix("~/") {
127+ if let Ok(home) = env::var("HOME") {
128+ return PathBuf::from(home).join(stripped);
129+ }
130+ } else if value == "~"
131+ && let Ok(home) = env::var("HOME")
132+ {
133+ return PathBuf::from(home);
134+ }
135+ PathBuf::from(value)
136+}
137+
138+pub struct VmArg {
139+ pub cpu_count: usize,
140+ pub ram_bytes: u64,
141+ pub disk_bytes: u64,
142+ pub no_default_mounts: bool,
143+ pub mounts: Vec<String>,
144+}
145+
146+type StatusEmitter<'a> = dyn Fn(&str) + std::marker::Send + Sync + 'a;
147+
148+fn emit_status(status: Option<&StatusEmitter<'_>>, message: &str) {
149+ if let Some(status) = status {
150+ status(message);
151+ }
152+}
153+
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
125155where
126156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
127157{
128− run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new())
158+ run_with_args_and_extras(args, io_handler, Vec::new(), Vec::new(), None)
129159}
130160
131−pub(crate) fn run_with_args_and_extras<F>(
132− args: CliArgs,
161+pub fn run_with_args_and_extras<F>(
162+ args: VmArg,
133163 io_handler: F,
134164 extra_login_actions: Vec<LoginAction>,
135165 extra_directory_shares: Vec<DirectoryShare>,
136−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
137168where
138169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
139170{
@@ -142,7 +173,7 @@ where
142173 let project_root = env::current_dir()?;
143174 let project_name = project_root
144175 .file_name()
145− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
146177 .to_string_lossy()
147178 .into_owned();
148179
@@ -154,6 +185,10 @@ where
154185 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
155186
156187 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");
157192
158193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
159194 let base_compressed = cache_dir.join(basename_compressed);
@@ -172,28 +207,31 @@ where
172207 let mise_directory_share =
173208 DirectoryShare::new(guest_mise_cache, "/root/.local/share/mise".into(), false)?;
174209
175− let disk_path = if let Some(path) = args.disk {
176− if !path.exists() {
177− return Err(format!("Disk image does not exist: {}", path.display()).into());
178− }
179− path
180− } else {
181− ensure_default_image(
182− &base_raw,
183− &base_compressed,
184− &default_raw,
185− std::slice::from_ref(&mise_directory_share),
186− )?;
187− ensure_instance_disk(&instance_raw, &default_raw)?;
188−
189− instance_raw
190− };
210+ ensure_default_image(
211+ &base_raw,
212+ &base_compressed,
213+ &default_raw,
214+ std::slice::from_ref(&mise_directory_share),
215+ Some(&provision_log),
216+ status,
217+ )?;
218+ let _ = ensure_instance_disk(
219+ &instance_raw,
220+ &default_raw,
221+ ByteSize(args.disk_bytes),
222+ status,
223+ )?;
224+ let base_size = fs::metadata(&default_raw)?.len();
225+ let instance_size = fs::metadata(&instance_raw)?.len();
226+ let needs_resize = instance_size > base_size;
227+ let disk_path = instance_raw;
191228
192229 let mut login_actions = Vec::new();
193230 let mut directory_shares = Vec::new();
194231
195232 if !args.no_default_mounts {
196− login_actions.push(Send(format!("cd {project_name}")));
233+ let project_guest_dir = PathBuf::from(PROJECT_GUEST_BASE).join(project_name);
234+ login_actions.push(Send(format!("cd {}", project_guest_dir.display())));
197235
198236 // 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.
199237 // I couldn't find an alternative way to do this --- the MacOS sandbox doesn't apply to the Apple Virtualization system
@@ -201,27 +239,7 @@ where
201239 login_actions.push(Send(r"mount -t tmpfs tmpfs .git/".into()));
202240 }
203241
204− directory_shares.push(
205− DirectoryShare::new(
206− project_root,
207− PathBuf::from("/root/").join(project_name),
208− false,
209− )
210− .expect("Project directory must exist"),
211− );
212−
213242 directory_shares.push(mise_directory_share);
214−
215− // Add default shares, if they exist
216− for share in [
217− DirectoryShare::new(home.join(".codex"), "/usr/local/codex".into(), false),
218− DirectoryShare::new(home.join(".claude"), "/usr/local/claude".into(), false),
219− ]
220− .into_iter()
221− .flatten()
222− {
223− directory_shares.push(share)
224− }
225243 }
226244
227245 directory_shares.extend(extra_directory_shares);
@@ -230,199 +248,46 @@ where
230248 directory_shares.push(DirectoryShare::from_mount_spec(spec)?);
231249 }
232250
251+ if needs_resize {
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
253+ login_actions.push(Send(resize_cmd));
254+ }
255+
233256 if let Some(motd_action) = motd_login_action(&directory_shares) {
234257 login_actions.push(motd_action);
235258 }
236259
237260 login_actions.extend(extra_login_actions);
238261
239− // Any user-provided login actions must come after our system ones
240− login_actions.extend(args.login_actions);
241−
242262 run_vm_with_io(
243263 &disk_path,
244264 &login_actions,
245265 &directory_shares[..],
246266 args.cpu_count,
247267 args.ram_bytes,
268+ status,
248269 io_handler,
249270 )
250271}
251272
252−pub fn print_help() {
253− println!(
254− "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
255−
256−vibe [OPTIONS] [disk-image.raw]
257−
258−Options
259−
260− --help Print this help message.
261− --version Print the version (commit SHA).
262− --no-default-mounts Disable all default mounts.
263− --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
264− Defaults to read-write.
265− Errors if host-path does not exist.
266− --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
267− --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
268− --script <path/to/script.sh> Run script in VM.
269− --send <some-command> Type `some-command` followed by newline into the VM.
270− --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
271− If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
272−"
273− );
274−}
275−
276−pub fn print_version() {
277− println!("Vibe");
278− println!("https://github.com/lynaghk/vibe/");
279− println!("Git SHA: {}", env!("GIT_SHA"));
280−}
281−
282−pub struct CliArgs {
283− disk: Option<PathBuf>,
284− version: bool,
285− help: bool,
286− no_default_mounts: bool,
287− mounts: Vec<String>,
288− login_actions: Vec<LoginAction>,
289− cpu_count: usize,
290− ram_bytes: u64,
291−}
292−
293−impl CliArgs {
294− pub fn version(&self) -> bool {
295− self.version
296− }
297−
298− pub fn help(&self) -> bool {
299− self.help
300− }
301−
302− pub fn cpu_count(&self) -> usize {
303− self.cpu_count
304− }
305−
306− pub fn ram_bytes(&self) -> u64 {
307− self.ram_bytes
308− }
309−
310− pub fn ram_mb(&self) -> u64 {
311− self.ram_bytes / BYTES_PER_MB
312− }
313−}
314−
315−pub fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
316− parse_cli_from(env::args_os())
317−}
318−
319−pub fn parse_cli_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
320−where
321− I: IntoIterator<Item = OsString>,
322−{
323− fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
324− value
325− .into_string()
326− .map_err(|_| format!("{flag} expects valid UTF-8").into())
327− }
328−
329− let mut parser = lexopt::Parser::from_iter(args);
330− let mut disk = None;
331− let mut version = false;
332− let mut help = false;
333− let mut no_default_mounts = false;
334− let mut mounts = Vec::new();
335− let mut login_actions = Vec::new();
336− let mut script_index = 0;
337− let mut cpu_count = DEFAULT_CPU_COUNT;
338− let mut ram_bytes = DEFAULT_RAM_BYTES;
339−
340− while let Some(arg) = parser.next()? {
341− match arg {
342− Long("version") => version = true,
343− Long("help") | Short('h') => help = true,
344− Long("no-default-mounts") => no_default_mounts = true,
345− Long("cpus") => {
346− let value = os_to_string(parser.value()?, "--cpus")?.parse()?;
347− if value == 0 {
348− return Err("--cpus must be >= 1".into());
349− }
350− cpu_count = value;
351− }
352− Long("ram") => {
353− let value: u64 = os_to_string(parser.value()?, "--ram")?.parse()?;
354− if value == 0 {
355− return Err("--ram must be >= 1".into());
356− }
357− ram_bytes = value * BYTES_PER_MB;
358− }
359− Long("mount") => {
360− mounts.push(os_to_string(parser.value()?, "--mount")?);
361− }
362− Long("script") => {
363− login_actions.push(Script {
364− path: os_to_string(parser.value()?, "--script")?.into(),
365− index: script_index,
366− });
367− script_index += 1;
368− }
369− Long("send") => {
370− login_actions.push(Send(os_to_string(parser.value()?, "--send")?));
371− }
372− Long("expect") => {
373− let text = os_to_string(parser.value()?, "--expect")?;
374− let timeout = match parser.optional_value() {
375− Some(value) => Duration::from_secs(os_to_string(value, "--expect")?.parse()?),
376− None => DEFAULT_EXPECT_TIMEOUT,
377− };
378− login_actions.push(Expect { text, timeout });
379− }
380− Value(value) => {
381− if disk.is_some() {
382− return Err("Only one disk path may be provided".into());
383− }
384− disk = Some(PathBuf::from(value));
385− }
386− _ => return Err(arg.unexpected().into()),
387− }
388− }
389−
390− Ok(CliArgs {
391− disk,
392− version,
393− help,
394− no_default_mounts,
395− mounts,
396− login_actions,
397− cpu_count,
398− ram_bytes,
399− })
400−}
401−
402−fn script_command_from_path(
403− path: &Path,
404− index: usize,
405−) -> Result<String, Box<dyn std::error::Error>> {
406− let script = fs::read_to_string(path)
407− .map_err(|err| format!("Failed to read script {}: {err}", path.display()))?;
408− let label = format!("{}_{}", index, path.file_name().unwrap().display());
409− script_command_from_content(&label, &script)
410−}
411−
412−pub(crate) fn script_command_from_content(
413− label: &str,
414− script: &str,
415−) -> Result<String, Box<dyn std::error::Error>> {
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
416274 let marker = "VIBE_SCRIPT_EOF";
417275 let guest_dir = "/tmp/vibe-scripts";
418276 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,
279+ _ => script,
280+ };
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
419284 let command = format!(
420− "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}"
421286 );
422287 if script.contains(marker) {
423− return Err(
424− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
425− );
288+ bail!(format!(
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
290+ ));
426291 }
427292 Ok(command)
428293}
@@ -486,6 +351,12 @@ enum WaitResult {
486351 Found,
487352}
488353
354+#[derive(PartialEq, Eq)]
355+enum WaitAnyResult {
356+ Timeout,
357+ Found(usize),
358+}
359+
489360pub enum VmInput {
490361 Bytes(Vec<u8>),
491362 Shutdown,
@@ -493,6 +364,7 @@ pub enum VmInput {
493364
494365enum VmOutput {
495366 LoginActionTimeout { action: String, timeout: Duration },
367+ LoginActionFailed { action: String, reason: String },
496368}
497369
498370#[derive(Default)]
@@ -529,6 +401,41 @@ impl OutputMonitor {
529401 WaitResult::Found
530402 }
531403 }
404+
405+ fn wait_for_any(&self, needles: &[&str], timeout: Duration) -> WaitAnyResult {
406+ let mut found: Option<usize> = None;
407+ let (_unused, timeout_result) = self
408+ .condvar
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();
412+ found = Some(idx);
413+ false
414+ } else {
415+ true
416+ }
417+ })
418+ .unwrap();
419+
420+ if timeout_result.timed_out() {
421+ WaitAnyResult::Timeout
422+ } else {
423+ WaitAnyResult::Found(found.unwrap_or(0))
424+ }
425+ }
426+}
427+
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);
435+ }
436+ }
437+ }
438+ best
532439}
533440
534441#[derive(Debug)]
@@ -577,15 +484,17 @@ impl IoControl {
577484fn ensure_base_image(
578485 base_raw: &Path,
579486 base_compressed: &Path,
580−) -> Result<(), Box<dyn std::error::Error>> {
487+ status: Option<&StatusEmitter<'_>>,
488+) -> Result<()> {
581489 if base_raw.exists() {
582490 return Ok(());
583491 }
584492
585493 if !base_compressed.exists()
586− || 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
587495 {
588− println!("Downloading base image...");
496+ emit_status(status, "downloading base image...");
497+ tracing::info!("downloading base image");
589498 let status = Command::new("curl")
590499 .args([
591500 "--continue-at",
@@ -599,12 +508,14 @@ fn ensure_base_image(
599508 ])
600509 .status()?;
601510 if !status.success() {
602− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
603512 }
604513 }
605514
606515 // Check SHA
607516 {
517+ emit_status(status, "verifying base image...");
518+ tracing::info!("verifying base image...");
608519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
609520
610521 let mut child = Command::new("/usr/bin/shasum")
@@ -622,22 +533,25 @@ fn ensure_base_image(
622533
623534 let status = child.wait().expect("failed to wait on child");
624535 if !status.success() {
625− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
536+ bail!(format!(
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
626539 }
627540 }
628541
629− println!("Decompressing base image...");
542+ emit_status(status, "decompressing base image...");
543+ tracing::info!("decompressing base image...");
630544 let status = Command::new("tar")
631545 .args([
632546 "-xOf",
633547 &base_compressed.to_string_lossy(),
634548 BASE_DISK_RAW_NAME,
635549 ])
636− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
637551 .status()?;
638552
639553 if !status.success() {
640− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
641555 }
642556
643557 Ok(())
@@ -648,24 +562,56 @@ fn ensure_default_image(
648562 base_compressed: &Path,
649563 default_raw: &Path,
650564 directory_shares: &[DirectoryShare],
651−) -> Result<(), Box<dyn std::error::Error>> {
565+ provision_log: Option<&Path>,
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
652568 if default_raw.exists() {
653569 return Ok(());
654570 }
655571
656− ensure_base_image(base_raw, base_compressed)?;
572+ ensure_base_image(base_raw, base_compressed, status)?;
657573
658− println!("Configuring base image...");
574+ emit_status(status, "configuring base image...");
575+ tracing::info!("configuring base image...");
659576 fs::copy(base_raw, default_raw)?;
660577
661− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
662− run_vm(
663− default_raw,
664− &[Send(provision_command)],
665− directory_shares,
666− DEFAULT_CPU_COUNT,
667− DEFAULT_RAM_BYTES,
668− )?;
578+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
579+ let provision_actions = [
580+ Send(provision_command),
581+ ExpectEither {
582+ success: "VIBEBOX_PROVISION_OK".to_string(),
583+ failure: "VIBEBOX_PROVISION_FAILED".to_string(),
584+ timeout: PROVISION_EXPECT_TIMEOUT,
585+ },
586+ ];
587+ let provision_result = if let Some(log_path) = provision_log {
588+ let log_path = log_path.to_path_buf();
589+ run_vm_with_io(
590+ default_raw,
591+ &provision_actions,
592+ directory_shares,
593+ BoxConfig::default().cpu_count,
594+ BoxConfig::default().ram_size.as_u64(),
595+ status,
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)
598+ },
599+ )
600+ } else {
601+ run_vm(
602+ default_raw,
603+ &provision_actions,
604+ directory_shares,
605+ BoxConfig::default().cpu_count,
606+ BoxConfig::default().ram_size.as_u64(),
607+ status,
608+ )
609+ };
610+
611+ if let Err(err) = provision_result {
612+ let _ = fs::remove_file(default_raw);
613+ return Err(err);
614+ }
669615
670616 Ok(())
671617}
@@ -673,15 +619,47 @@ fn ensure_default_image(
673619fn ensure_instance_disk(
674620 instance_raw: &Path,
675621 template_raw: &Path,
676−) -> Result<(), Box<dyn std::error::Error>> {
622+ target_bytes: ByteSize,
623+ status: Option<&StatusEmitter<'_>>,
624+) -> Result<bool> {
677625 if instance_raw.exists() {
678− return Ok(());
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;
630+ tracing::warn!(
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.",
632+ current_gb,
633+ target_gb
634+ );
635+ }
636+ return Ok(false);
679637 }
680638
681− println!("Creating instance disk from {}...", template_raw.display());
682− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
683− fs::copy(template_raw, instance_raw)?;
684− Ok(())
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
640+ if target_bytes < template_size {
641+ bail!(format!(
642+ "Requested disk size {} is smaller than base image size {}",
643+ target_bytes, template_size
644+ ));
645+ }
646+ let target_size = target_bytes;
647+ let needs_resize = target_size > template_size;
648+
649+ emit_status(status, "creating instance disk...");
650+ tracing::info!("creating instance disk...");
651+ tracing::info!(path = %template_raw.display(), "creating instance disk");
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
653+ if target_size == template_size {
654+ fs::copy(template_raw, instance_raw)?;
655+ return Ok(needs_resize);
656+ }
657+
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)?;
662+ Ok(needs_resize)
685663}
686664
687665pub struct IoContext {
@@ -697,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
697675 (read_stream.into(), write_stream.into())
698676}
699677
700−pub fn spawn_vm_io_with_hooks<F, G>(
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,
681+>(
701682 output_monitor: Arc<OutputMonitor>,
702683 vm_output_fd: OwnedFd,
703684 vm_input_fd: OwnedFd,
704685 io_control: Arc<IoControl>,
705686 mut on_line: F,
706687 mut on_output: G,
707−) -> IoContext
708−where
709− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
710− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
711−{
688+) -> IoContext {
712689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
713690
714691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -856,14 +833,14 @@ where
856833 PollResult::Spurious => continue,
857834 PollResult::Ready(bytes) => {
858835 if io_control.forward_output() {
859− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
860837 if raw_guard.lock().unwrap().is_none()
861838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
862839 {
863840 *raw_guard.lock().unwrap() = Some(guard);
864841 }
865842
866− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
867844 if stdout.write_all(bytes).is_err() {
868845 break;
869846 }
@@ -879,7 +856,7 @@ where
879856
880857 // Copies data from mpsc channel into VM, so vibe can "type" stuff and run scripts.
881858 let mux_thread = thread::spawn(move || {
882− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
883860 loop {
884861 match input_rx.recv() {
885862 Ok(VmInput::Bytes(data)) => {
@@ -909,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
909886 on_line: F,
910887) -> IoContext
911888where
912− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
913890{
914891 spawn_vm_io_with_hooks(
915892 output_monitor,
@@ -929,6 +906,36 @@ pub fn spawn_vm_io(
929906 spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
930907}
931908
909+fn spawn_vm_io_with_log(
910+ output_monitor: Arc<OutputMonitor>,
911+ vm_output_fd: OwnedFd,
912+ vm_input_fd: OwnedFd,
913+ log_path: PathBuf,
914+) -> IoContext {
915+ let log_file = fs::OpenOptions::new()
916+ .create(true)
917+ .write(true)
918+ .truncate(true)
919+ .open(&log_path)
920+ .ok()
921+ .map(|file| Arc::new(Mutex::new(file)));
922+
923+ spawn_vm_io_with_hooks(
924+ output_monitor,
925+ vm_output_fd,
926+ vm_input_fd,
927+ IoControl::new(),
928+ |_| false,
929+ move |bytes| {
930+ if let Some(log) = &log_file
931+ && let Ok(mut file) = log.lock()
932+ {
933+ let _ = file.write_all(bytes);
934+ }
935+ },
936+ )
937+}
938+
932939impl IoContext {
933940 pub fn shutdown(self) {
934941 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -946,7 +953,7 @@ fn create_vm_configuration(
946953 vm_writes_to_fd: OwnedFd,
947954 cpu_count: usize,
948955 ram_bytes: u64,
949−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
950957 unsafe {
951958 let platform =
952959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -980,7 +987,7 @@ fn create_vm_configuration(
980987 false,
981988 VZDiskImageCachingMode::Automatic,
982989 VZDiskImageSynchronizationMode::Full,
983− ).unwrap();
990+ )?;
984991
985992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
986993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -1067,7 +1074,7 @@ fn create_vm_configuration(
10671074 // Validate
10681075 config.validateWithError().map_err(|e| {
10691076 io::Error::other(format!(
1070− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
10711078 e.localizedDescription()
10721079 ))
10731080 })?;
@@ -1076,9 +1083,9 @@ fn create_vm_configuration(
10761083 }
10771084}
10781085
1079−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
10801087 unsafe {
1081− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
10821089 let temp_path = temp_dir.join(format!("efi_variable_store_{}.efivars", std::process::id()));
10831090 let url = nsurl_from_path(&temp_path)?;
10841091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1094,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
10941101fn spawn_login_actions_thread(
10951102 login_actions: Vec<LoginAction>,
10961103 output_monitor: Arc<OutputMonitor>,
1097− input_tx: mpsc::Sender<VmInput>,
1098− vm_output_tx: mpsc::Sender<VmOutput>,
1104+ input_tx: Sender<VmInput>,
1105+ vm_output_tx: Sender<VmOutput>,
10991106) -> thread::JoinHandle<()> {
11001107 thread::spawn(move || {
11011108 for a in login_actions {
@@ -1109,22 +1116,31 @@ fn spawn_login_actions_thread(
11091116 return;
11101117 }
11111118 }
1119+ ExpectEither {
1120+ success,
1121+ failure,
1122+ timeout,
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),
1129+ });
1130+ return;
1131+ }
1132+ WaitAnyResult::Timeout => {
1133+ let _ = vm_output_tx.send(VmOutput::LoginActionTimeout {
1134+ action: format!("expect '{}'", success),
1135+ timeout,
1136+ });
1137+ return;
1138+ }
1139+ },
11121140 Send(mut text) => {
11131141 text.push('\n'); // Type the newline so the command is actually submitted.
11141142 input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
11151143 }
1116− Script { path, index } => {
1117− let command = match script_command_from_path(&path, index) {
1118− Ok(command) => command,
1119− Err(err) => {
1120− eprintln!("{err}");
1121− return;
1122− }
1123− };
1124− let mut text = command;
1125− text.push('\n');
1126− input_tx.send(VmInput::Bytes(text.into_bytes())).unwrap();
1127− }
11281144 }
11291145 }
11301146 })
@@ -1136,8 +1152,9 @@ fn run_vm_with_io<F>(
11361152 directory_shares: &[DirectoryShare],
11371153 cpu_count: usize,
11381154 ram_bytes: u64,
1155+ status: Option<&StatusEmitter<'_>>,
11391156 io_handler: F,
1140−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
11411158where
11421159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
11431160{
@@ -1184,21 +1201,23 @@ where
11841201
11851202 match rx.try_recv() {
11861203 Ok(result) => {
1187− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
11881205 break;
11891206 }
11901207 Err(mpsc::TryRecvError::Empty) => continue,
11911208 Err(mpsc::TryRecvError::Disconnected) => {
1192− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
11931210 }
11941211 }
11951212 }
11961213
11971214 if Instant::now() >= start_deadline {
1198− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
11991216 }
12001217
1201− println!("VM booting...");
1218+ emit_status(status, "vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1220+ tracing::info!("vm booting");
12021221
12031222 let output_monitor = Arc::new(OutputMonitor::default());
12041223 let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
@@ -1243,7 +1262,7 @@ where
12431262 );
12441263
12451264 let mut last_state = None;
1246− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
12471266 loop {
12481267 unsafe {
12491268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1262,12 +1281,28 @@ where
12621281 exit_result = Err(format!(
12631282 "Login action ({}) timed out after {:?}; shutting down.",
12641283 action, timeout
1265− )
1266− .into());
1284+ ));
1285+ unsafe {
1286+ if vm.canRequestStop() {
1287+ if let Err(err) = vm.requestStopWithError() {
1288+ tracing::error!(error = ?err, "failed to request VM stop");
1289+ }
1290+ } else if vm.canStop() {
1291+ let handler = RcBlock::new(|_error: *mut NSError| {});
1292+ vm.stopWithCompletionHandler(&handler);
1293+ }
1294+ }
1295+ break;
1296+ }
1297+ Ok(VmOutput::LoginActionFailed { action, reason }) => {
1298+ exit_result = Err(format!(
1299+ "Login action ({}) failed: {}; shutting down.",
1300+ action, reason
1301+ ));
12671302 unsafe {
12681303 if vm.canRequestStop() {
12691304 if let Err(err) = vm.requestStopWithError() {
1270− eprintln!("Failed to request VM stop: {:?}", err);
1305+ tracing::error!(error = ?err, "failed to request VM stop");
12711306 }
12721307 } else if vm.canStop() {
12731308 let handler = RcBlock::new(|_error: *mut NSError| {});
@@ -1279,7 +1314,7 @@ where
12791314 Err(mpsc::TryRecvError::Empty) => {}
12801315 Err(mpsc::TryRecvError::Disconnected) => {}
12811316 }
1282− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
12831318 //eprintln!("VM stopped with state: {:?}", state);
12841319 break;
12851320 }
@@ -1289,7 +1324,7 @@ where
12891324
12901325 io_ctx.shutdown();
12911326
1292− exit_result
1327+ exit_result.map_err(Error::msg)
12931328}
12941329
12951330fn run_vm(
@@ -1298,18 +1333,20 @@ fn run_vm(
12981333 directory_shares: &[DirectoryShare],
12991334 cpu_count: usize,
13001335 ram_bytes: u64,
1301−) -> Result<(), Box<dyn std::error::Error>> {
1336+ status: Option<&StatusEmitter<'_>>,
1337+) -> Result<()> {
13021338 run_vm_with_io(
13031339 disk_path,
13041340 login_actions,
13051341 directory_shares,
13061342 cpu_count,
13071343 ram_bytes,
1344+ status,
13081345 spawn_vm_io,
13091346 )
13101347}
13111348
1312−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
13131350 let abs_path = if path.is_absolute() {
13141351 path.to_path_buf()
13151352 } else {
@@ -1318,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
13181355 let ns_path = NSString::from_str(
13191356 abs_path
13201357 .to_str()
1321− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
13221359 );
13231360 Ok(NSURL::fileURLWithPath(&ns_path))
13241361}
@@ -1333,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
13331370 let original = attributes;
13341371
13351372 // Disable translation of carriage return to newline on input
1336− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
13371374 // Disable canonical mode (line buffering), echo, and signal generation
13381375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
13391376 attributes.c_cc[libc::VMIN] = 0;
@@ -1361,10 +1398,10 @@ impl Drop for RawModeGuard {
13611398
13621399// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
13631400pub fn ensure_signed() {
1364− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
13651402 return;
13661403 }
1367− 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");
13681405 let exe_str = exe.to_str().expect("exe path not valid utf-8");
13691406
13701407 let has_required_entitlements = {
@@ -1386,10 +1423,10 @@ pub fn ensure_signed() {
13861423 }
13871424
13881425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1389− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1390− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
13911428
1392− let status = Command::new("codesign")
1429+ let output = Command::new("codesign")
13931430 .args([
13941431 "--sign",
13951432 "-",
@@ -1398,22 +1435,27 @@ pub fn ensure_signed() {
13981435 entitlements_path.to_str().unwrap(),
13991436 exe_str,
14001437 ])
1401− .status();
1438+ .output();
14021439
1403− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
14041441
1405− match status {
1406− Ok(s) if s.success() => {
1407− let err = Command::new(&exe).args(std::env::args_os().skip(1)).exec();
1408− eprintln!("failed to re-exec after signing: {err}");
1442+ match output {
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");
1447+ }
1448+ let err = Command::new(&exe).args(env::args_os().skip(1)).exec();
1449+ tracing::error!(error = %err, "failed to re-exec after signing");
14091450 std::process::exit(1);
14101451 }
1411− Ok(s) => {
1412− eprintln!("codesign failed with status: {s}");
1452+ Ok(o) => {
1453+ let stderr = String::from_utf8_lossy(&o.stderr);
1454+ tracing::error!(status = %o.status, codesign_stderr = %stderr.trim(), "codesign failed");
14131455 std::process::exit(1);
14141456 }
14151457 Err(e) => {
1416− eprintln!("failed to run codesign: {e}");
1458+ tracing::error!(error = %e, "failed to run codesign");
14171459 std::process::exit(1);
14181460 }
14191461 }