on main help sign in
Refactor (#10)
23726d7Finn Sheng
Cargo.lock+17
@@ -82,6 +82,12 @@ dependencies = [
82 "windows-sys 0.61.2",
82 "windows-sys 0.61.2",
83]
83]
84
84
85+[[package]]
86+name = "anyhow"
87+version = "1.0.101"
88+source = "registry+https://github.com/rust-lang/crates.io-index"
89+checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
90+
85[[package]]
91[[package]]
86name = "assert_cmd"
92name = "assert_cmd"
87version = "2.1.2"
93version = "2.1.2"
@@ -144,6 +150,15 @@ version = "3.19.1"
144source = "registry+https://github.com/rust-lang/crates.io-index"
150source = "registry+https://github.com/rust-lang/crates.io-index"
145checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
151checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
146
152
153+[[package]]
154+name = "bytesize"
155+version = "2.3.1"
156+source = "registry+https://github.com/rust-lang/crates.io-index"
157+checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3"
158+dependencies = [
159+ "serde_core",
160+]
161+
147[[package]]
162[[package]]
148name = "cassowary"
163name = "cassowary"
149version = "0.3.0"
164version = "0.3.0"
@@ -1305,8 +1320,10 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1305name = "vibebox"
1320name = "vibebox"
1306version = "0.3.1"
1321version = "0.3.1"
1307dependencies = [
1322dependencies = [
1323+ "anyhow",
1308 "assert_cmd",
1324 "assert_cmd",
1309 "block2",
1325 "block2",
1326+ "bytesize",
1310 "clap",
1327 "clap",
1311 "color-eyre",
1328 "color-eyre",
1312 "crossterm",
1329 "crossterm",
Cargo.toml+2
@@ -42,6 +42,8 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
42tracing = "0.1"
42tracing = "0.1"
43tracing-subscriber = { version = "0.3", features = ["env-filter"] }
43tracing-subscriber = { version = "0.3", features = ["env-filter"] }
44dialoguer = "0.12.0"
44dialoguer = "0.12.0"
45+bytesize = {version = "2.3.1",features = ["serde"]}
46+anyhow = "1.0.101"
45
47
46[dev-dependencies]
48[dev-dependencies]
47assert_cmd = "2"
49assert_cmd = "2"
docs/tasks.md+14−10
@@ -25,7 +25,7 @@
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
255. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
266. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio.
277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
277. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration.
28−8. [ ] Run tests and validate coverage for the new module.
28+8. [x] Run tests and validate coverage for the new module.
29
29
30## TUI
30## TUI
31
31
@@ -49,19 +49,23 @@
49 4. [x] set up the cli.
49 4. [x] set up the cli.
509. [x] fix ui overlap, and consistency issue.
509. [x] fix ui overlap, and consistency issue.
5110. [x] `purge-cache` to clear the cache.
5110. [x] `purge-cache` to clear the cache.
52−11. [ ] intensive integration test.
52+11. [x] intensive integration test.
53
53
54## Publish
54## Publish
55
55
56−1. [ ] write the docs.
56+1. [x] write the docs.
57−2. [ ] setup quick install link.
57+2. [x] setup quick install link.
58−3. [ ] setup website.
58+3. [x] setup website.
59
59
60## Stage 2
60## Stage 2
61
61
621. [ ] retouch the cli ux.
621. [ ] retouch the cli ux.
63−2. [ ] refactor the code.
63+2. [x] refactor the code.
64+3. [ ] refactor the mount system.
65+4. [x] refactor the vm process lifetime.
64−3. [ ] Redirect vm output to log.
66+5. [x] Redirect vm output to log.
65−4. [ ] Redirect vm output to vibebox starting it.
67+6. [x] Redirect vm output to vibebox starting it.
66−5. [ ] use anyhow to sync api.
68+7. [x] use anyhow to sync api.
67−6. [ ] add support for ipv6.
69+8. [ ] add support for ipv6.
70+9. [x] use UnixStream instead of status file
71+10. [x] liveness check should also happen when waiting for ssh port
src/bin/vibebox-supervisor.rs+8−7
@@ -21,15 +21,16 @@ fn main() -> Result<()> {
21 color_eyre::install()?;
21 color_eyre::install()?;
22
22
23 tracing::info!("starting vm supervisor");
23 tracing::info!("starting vm supervisor");
24− let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
24+ let project_dir = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
25− let config = config::load_config(&cwd);
25+ let config = config::load_config(&project_dir)
26− let instance_dir = instance::ensure_instance_dir(&cwd)
27 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
26 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
27+ let _ = instance::ensure_instance_dir(&project_dir)
28+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
28− let _ = instance::touch_last_active(&instance_dir);
29+ let _ = instance::touch_last_active(&project_dir);
29 let args = vm::VmArg {
30 let args = vm::VmArg {
30 cpu_count: config.box_cfg.cpu_count,
31 cpu_count: config.box_cfg.cpu_count,
31− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
32+ ram_bytes: config.box_cfg.ram_size.as_u64(),
32− disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
33+ disk_bytes: config.box_cfg.disk_size.as_u64(),
33 no_default_mounts: false,
34 no_default_mounts: false,
34 mounts: config.box_cfg.mounts.clone(),
35 mounts: config.box_cfg.mounts.clone(),
35 };
36 };
@@ -37,7 +38,7 @@ fn main() -> Result<()> {
37 tracing::info!(auto_shutdown_ms, "vm supervisor config");
38 tracing::info!(auto_shutdown_ms, "vm supervisor config");
38
39
39 let result = vm_manager::run_manager(args, auto_shutdown_ms);
40 let result = vm_manager::run_manager(args, auto_shutdown_ms);
40− let _ = instance::touch_last_active(&instance_dir);
41+ let _ = instance::touch_last_active(&project_dir);
41 if let Err(err) = result {
42 if let Err(err) = result {
42 tracing::error!(error = %err, "vm supervisor exited");
43 tracing::error!(error = %err, "vm supervisor exited");
43 return Err(color_eyre::eyre::eyre!(err.to_string()));
44 return Err(color_eyre::eyre::eyre!(err.to_string()));
src/bin/vibebox.rs+53−95
@@ -1,3 +1,7 @@
1+use bytesize::ByteSize;
2+use clap::Parser;
3+use color_eyre::Result;
4+use dialoguer::Confirm;
1use std::{
5use std::{
2 env,
6 env,
3 ffi::OsString,
7 ffi::OsString,
@@ -6,10 +10,6 @@ use std::{
6 path::{Path, PathBuf},
10 path::{Path, PathBuf},
7 sync::{Arc, Mutex},
11 sync::{Arc, Mutex},
8};
12};
9−
10−use clap::Parser;
11−use color_eyre::Result;
12−use dialoguer::Confirm;
13use time::OffsetDateTime;
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
14use time::format_description::well_known::Rfc3339;
15use tracing_subscriber::filter::LevelFilter;
15use tracing_subscriber::filter::LevelFilter;
@@ -17,8 +17,9 @@ use tracing_subscriber::registry::Registry;
17use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
17use tracing_subscriber::{EnvFilter, fmt, prelude::*, reload};
18
18
19use vibebox::tui::{AppState, VmInfo};
19use vibebox::tui::{AppState, VmInfo};
20+use vibebox::utils::relative_to_home;
20use vibebox::{
21use vibebox::{
21− SessionManager, commands, config, explain, instance, session_manager, tui, vm, vm_manager,
22+ SessionManager, commands, config, explain, instance, session_manager, tui, vm_manager,
22};
23};
23
24
24#[derive(Debug, Parser)]
25#[derive(Debug, Parser)]
@@ -56,53 +57,26 @@ fn main() -> Result<()> {
56
57
57 let config_override = cli.config.clone();
58 let config_override = cli.config.clone();
58 let raw_args: Vec<OsString> = env::args_os().collect();
59 let raw_args: Vec<OsString> = env::args_os().collect();
59− let config = config::load_config_with_path(&cwd, config_override.as_deref());
60+ let config = config::load_config_with_path(&cwd, config_override.as_deref())
61+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
60
62
61− if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
62− tracing::info!("starting vm manager mode");
63− let args = vm::VmArg {
64− cpu_count: config.box_cfg.cpu_count,
65− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
66− disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
67− no_default_mounts: false,
68− mounts: config.box_cfg.mounts.clone(),
69− };
70− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
71− tracing::info!(auto_shutdown_ms, "vm manager config");
72− if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
73− tracing::error!(error = %err, "vm manager exited");
74− return Err(color_eyre::eyre::eyre!(err.to_string()));
75− }
76− return Ok(());
77− }
78−
79− vm::ensure_signed();
80−
81− let vm_args = vm::VmArg {
82− cpu_count: config.box_cfg.cpu_count,
83− ram_bytes: config.box_cfg.ram_mb.saturating_mul(1024 * 1024),
84− disk_bytes: config.box_cfg.disk_gb.saturating_mul(1024 * 1024 * 1024),
85− no_default_mounts: false,
86− mounts: config.box_cfg.mounts.clone(),
87− };
88− let auto_shutdown_ms = config.supervisor.auto_shutdown_ms;
89 let vm_info = VmInfo {
63 let vm_info = VmInfo {
90− max_memory_mb: vm_args.ram_bytes / (1024 * 1024),
64+ max_memory: config.box_cfg.ram_size,
91− cpu_cores: vm_args.cpu_count,
65+ cpu_cores: config.box_cfg.cpu_count,
92− max_disk_gb: (vm_args.disk_bytes as f32) / 1024.0 / 1024.0 / 1024.0,
66+ max_disk: config.box_cfg.disk_size,
93 system_name: "Debian".to_string(), // TODO: read system name from the VM.
67 system_name: "Debian".to_string(), // TODO: read system name from the VM.
94− auto_shutdown_ms,
68+ auto_shutdown_ms: config.supervisor.auto_shutdown_ms,
95 };
69 };
96 if let Ok(manager) = SessionManager::new() {
70 if let Ok(manager) = SessionManager::new() {
97 if let Err(err) = manager.update_global_sessions(&cwd) {
71 if let Err(err) = manager.update_global_sessions(&cwd) {
98 tracing::warn!(error = %err, "failed to update a global session list");
72 tracing::warn!(error = %err, "failed to update a global session list");
99 }
73 }
100 } else {
74 } else {
101− tracing::warn!("failed to initialize session manager");
75+ tracing::error!("failed to initialize session manager");
76+ std::process::exit(1);
102 }
77 }
103 let commands = commands::build_commands();
78 let commands = commands::build_commands();
104 let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
79 let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info, commands)));
105−
106 {
80 {
107 let mut locked = app.lock().expect("app state poisoned");
81 let mut locked = app.lock().expect("app state poisoned");
108 tui::render_tui_once(&mut locked)?;
82 tui::render_tui_once(&mut locked)?;
@@ -112,24 +86,40 @@ fn main() -> Result<()> {
112 writeln!(stdout)?;
86 writeln!(stdout)?;
113 stdout.flush()?;
87 stdout.flush()?;
114 }
88 }
115− warn_disk_size_mismatch(&cwd, vm_args.disk_bytes);
89+ warn_disk_size_mismatch(&cwd, config.box_cfg.disk_size);
116 if let Some(handle) = stderr_handle {
90 if let Some(handle) = stderr_handle {
117 let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
91 let _ = handle.modify(|filter| *filter = LevelFilter::INFO);
118 }
92 }
119
93
120− tracing::debug!(auto_shutdown_ms, "auto shutdown config");
94+ tracing::debug!(config.supervisor.auto_shutdown_ms, "auto shutdown config");
121− let manager_conn =
95+ let manager_conn = vm_manager::ensure_manager(
96+ &raw_args,
97+ config.supervisor.auto_shutdown_ms,
122− vm_manager::ensure_manager(&raw_args, auto_shutdown_ms, config_override.as_deref())
98+ config_override.as_deref(),
123− .map_err(|err| {
124− tracing::error!(error = %err, "failed to ensure vm manager");
125− color_eyre::eyre::eyre!(err.to_string())
126− })?;
99+ )
127−
100+ .map_err(|err| {
128− instance::run_with_ssh(manager_conn).map_err(|err| {
129 tracing::error!(error = %err, "failed to ensure vm manager");
101 tracing::error!(error = %err, "failed to ensure vm manager");
130 color_eyre::eyre::eyre!(err.to_string())
102 color_eyre::eyre::eyre!(err.to_string())
131 })?;
103 })?;
132
104
105+ if let Err(err) = instance::run_with_ssh(manager_conn) {
106+ if let Some(instance::InstanceError::UnexpectedDisconnection) =
107+ err.downcast_ref::<instance::InstanceError>()
108+ {
109+ tracing::warn!("vm manager disconnected; exiting vibebox");
110+ } else if let Some(instance::InstanceError::VMError(vm_error)) =
111+ err.downcast_ref::<instance::InstanceError>()
112+ {
113+ tracing::error!("[vm]: {vm_error}");
114+ tracing::info!("vibecoding paused: the VM says today is a rest day 😴");
115+ std::process::exit(1);
116+ } else {
117+ let message = err.to_string();
118+ tracing::error!(error = %message, "vibebox exited: uncaught error");
119+ return Err(color_eyre::eyre::eyre!(message));
120+ }
121+ }
122+
133 tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
123 tracing::info!("See you again — keep vibecoding (no SEVs, only vibes) 😈");
134
124
135 Ok(())
125 Ok(())
@@ -211,13 +201,14 @@ fn handle_command(command: Command, cwd: &Path, config_override: Option<&Path>)
211 "Purged {} file{} totaling {} from {}",
201 "Purged {} file{} totaling {} from {}",
212 file_count,
202 file_count,
213 if file_count == 1 { "" } else { "s" },
203 if file_count == 1 { "" } else { "s" },
214− format_bytes(total_bytes),
204+ ByteSize(total_bytes),
215 cache_dir.display()
205 cache_dir.display()
216 );
206 );
217 Ok(())
207 Ok(())
218 }
208 }
219 Command::Explain => {
209 Command::Explain => {
220− let config = config::load_config_with_path(cwd, config_override);
210+ let config = config::load_config_with_path(cwd, config_override)
211+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
221 let mounts = explain::build_mount_rows(cwd, &config)
212 let mounts = explain::build_mount_rows(cwd, &config)
222 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
213 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
223 let networks = explain::build_network_rows(cwd)
214 let networks = explain::build_network_rows(cwd)
@@ -240,20 +231,6 @@ fn project_name(directory: &Path) -> String {
240 .to_string()
231 .to_string()
241}
232}
242
233
243−fn relative_to_home(directory: &Path) -> String {
244− let Ok(home) = env::var("HOME") else {
245− return directory.display().to_string();
246− };
247− let home_path = PathBuf::from(home);
248− if let Ok(stripped) = directory.strip_prefix(&home_path) {
249− if stripped.components().next().is_none() {
250− return "~".to_string();
251− }
252− return format!("~/{}", stripped.display());
253− }
254− directory.display().to_string()
255−}
256−
257fn cache_dir() -> Result<PathBuf> {
234fn cache_dir() -> Result<PathBuf> {
258 let home = env::var("HOME").map(PathBuf::from)?;
235 let home = env::var("HOME").map(PathBuf::from)?;
259 let cache_home = env::var("XDG_CACHE_HOME")
236 let cache_home = env::var("XDG_CACHE_HOME")
@@ -302,23 +279,6 @@ fn measure_dir(path: &Path) -> Result<(u64, u64)> {
302 Ok((file_count, total_bytes))
279 Ok((file_count, total_bytes))
303}
280}
304
281
305−fn format_bytes(bytes: u64) -> String {
306− const KB: f64 = 1024.0;
307− const MB: f64 = KB * 1024.0;
308− const GB: f64 = MB * 1024.0;
309−
310− let b = bytes as f64;
311− if b >= GB {
312− format!("{:.2} GB", b / GB)
313− } else if b >= MB {
314− format!("{:.1} MB", b / MB)
315− } else if b >= KB {
316− format!("{:.1} KB", b / KB)
317− } else {
318− format!("{} B", bytes)
319− }
320−}
321−
322fn format_last_active(value: Option<&str>) -> String {
282fn format_last_active(value: Option<&str>) -> String {
323 let Some(raw) = value else {
283 let Some(raw) = value else {
324 return "-".to_string();
284 return "-".to_string();
@@ -360,24 +320,22 @@ fn format_last_active(value: Option<&str>) -> String {
360 format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
320 format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
361}
321}
362
322
363−fn warn_disk_size_mismatch(cwd: &Path, configured_bytes: u64) {
323+fn warn_disk_size_mismatch(cwd: &Path, configured_size: ByteSize) {
364 let instance_raw = cwd
324 let instance_raw = cwd
365 .join(session_manager::INSTANCE_DIR_NAME)
325 .join(session_manager::INSTANCE_DIR_NAME)
366 .join("instance.raw");
326 .join("instance.raw");
367 let Ok(meta) = fs::metadata(&instance_raw) else {
327 let Ok(meta) = fs::metadata(&instance_raw) else {
368 return;
328 return;
369 };
329 };
370− let current_bytes = meta.len();
330+ let current_size = ByteSize::b(meta.len());
371− if current_bytes == configured_bytes {
331+ if current_size == configured_size {
372 return;
332 return;
373 }
333 }
374− let current_gb = current_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
375− let target_gb = configured_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
376 tracing::warn!(
334 tracing::warn!(
377− "instance disk size does not match config (current {:.2} GB, config {:.2} GB). \
335+ "instance disk size does not match config (current {}, config {}). \
378disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
336disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using the existing disk.",
379− current_gb,
337+ current_size,
380− target_gb
338+ configured_size,
381 );
339 );
382}
340}
383
341
@@ -386,13 +344,13 @@ type StderrHandle = reload::Handle<LevelFilter, Registry>;
386fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
344fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
387 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
345 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
388 let file_filter = filter.clone();
346 let file_filter = filter.clone();
389− let stderr_is_tty = std::io::stderr().is_terminal();
347+ let stderr_is_tty = io::stderr().is_terminal();
390 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
348 let ansi = stderr_is_tty && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
391 let file = instance::ensure_instance_dir(cwd)
349 let file = instance::ensure_instance_dir(cwd)
392 .ok()
350 .ok()
393 .and_then(|instance_dir| {
351 .and_then(|instance_dir| {
394 let log_path = instance_dir.join("cli.log");
352 let log_path = instance_dir.join("cli.log");
395− std::fs::OpenOptions::new()
353+ fs::OpenOptions::new()
396 .create(true)
354 .create(true)
397 .write(true)
355 .write(true)
398 .truncate(true)
356 .truncate(true)
@@ -406,7 +364,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
406 .with_target(false)
364 .with_target(false)
407 .with_ansi(ansi)
365 .with_ansi(ansi)
408 .without_time()
366 .without_time()
409− .with_writer(std::io::stderr)
367+ .with_writer(io::stderr)
410 .with_filter(stderr_filter);
368 .with_filter(stderr_filter);
411 let subscriber = tracing_subscriber::registry().with(stderr_layer);
369 let subscriber = tracing_subscriber::registry().with(stderr_layer);
412 if let Some(file) = file {
370 if let Some(file) = file {
@@ -424,7 +382,7 @@ fn init_tracing(cwd: &Path) -> Option<StderrHandle> {
424 let stderr_layer = fmt::layer()
382 let stderr_layer = fmt::layer()
425 .with_target(false)
383 .with_target(false)
426 .with_ansi(ansi)
384 .with_ansi(ansi)
427− .with_writer(std::io::stderr)
385+ .with_writer(io::stderr)
428 .with_filter(filter);
386 .with_filter(filter);
429 let subscriber = tracing_subscriber::registry().with(stderr_layer);
387 let subscriber = tracing_subscriber::registry().with(stderr_layer);
430 if let Some(file) = file {
388 if let Some(file) = file {
src/config.rs+467−77
@@ -1,13 +1,14 @@
1+use anyhow::{Context, Error, Result, bail};
2+use bytesize::ByteSize;
3+use serde::{Deserialize, Serialize};
1use std::{
4use std::{
2 env, fs, io,
5 env, fs, io,
3 path::{Path, PathBuf},
6 path::{Path, PathBuf},
4};
7};
5
8
6−use serde::{Deserialize, Serialize};
7−
8use crate::vm::DirectoryShare;
9use crate::vm::DirectoryShare;
9
10
10−pub const CONFIG_FILENAME: &str = "vibebox.toml";
11+const CONFIG_FILENAME: &str = "vibebox.toml";
11pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
12pub const CONFIG_PATH_ENV: &str = "VIBEBOX_CONFIG_PATH";
12
13
13const DEFAULT_CPU_COUNT: usize = 2;
14const DEFAULT_CPU_COUNT: usize = 2;
@@ -22,20 +23,83 @@ pub struct Config {
22 pub supervisor: SupervisorConfig,
23 pub supervisor: SupervisorConfig,
23}
24}
24
25
26+const MI_B: u64 = 1024 * 1024;
27+const GI_B: u64 = 1024 * 1024 * 1024;
28+
29+mod serde_mb {
30+ use super::{ByteSize, MI_B};
31+ use serde::{Deserialize, Deserializer, Serializer};
32+
33+ pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
34+ where
35+ S: Serializer,
36+ {
37+ let bytes = v.0;
38+ if !bytes.is_multiple_of(MI_B) {
39+ return Err(serde::ser::Error::custom(
40+ "ram_mb must be an integer number of MB",
41+ ));
42+ }
43+ s.serialize_u64(bytes / MI_B)
44+ }
45+
46+ pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
47+ where
48+ D: Deserializer<'de>,
49+ {
50+ let mb = u64::deserialize(d)?;
51+ let bytes = mb
52+ .checked_mul(MI_B)
53+ .ok_or_else(|| serde::de::Error::custom("ram_mb overflow"))?;
54+ Ok(ByteSize(bytes))
55+ }
56+}
57+
58+mod serde_gb {
59+ use super::{ByteSize, GI_B};
60+ use serde::{Deserialize, Deserializer, Serializer};
61+
62+ pub fn serialize<S>(v: &ByteSize, s: S) -> Result<S::Ok, S::Error>
63+ where
64+ S: Serializer,
65+ {
66+ let bytes = v.0;
67+ if !bytes.is_multiple_of(GI_B) {
68+ return Err(serde::ser::Error::custom(
69+ "disk_gb must be an integer number of GB",
70+ ));
71+ }
72+ s.serialize_u64(bytes / GI_B)
73+ }
74+
75+ pub fn deserialize<'de, D>(d: D) -> Result<ByteSize, D::Error>
76+ where
77+ D: Deserializer<'de>,
78+ {
79+ let gb = u64::deserialize(d)?;
80+ let bytes = gb
81+ .checked_mul(GI_B)
82+ .ok_or_else(|| serde::de::Error::custom("disk_gb overflow"))?;
83+ Ok(ByteSize(bytes))
84+ }
85+}
86+
25#[derive(Debug, Clone, Serialize, Deserialize)]
87#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct BoxConfig {
88pub struct BoxConfig {
27 pub cpu_count: usize,
89 pub cpu_count: usize,
28− pub ram_mb: u64,
90+ #[serde(rename = "ram_mb", with = "serde_mb")]
29− pub disk_gb: u64,
91+ pub ram_size: ByteSize,
92+ #[serde(rename = "disk_gb", with = "serde_gb")]
93+ pub disk_size: ByteSize,
30 pub mounts: Vec<String>,
94 pub mounts: Vec<String>,
31}
95}
32
96
33impl Default for BoxConfig {
97impl Default for BoxConfig {
34 fn default() -> Self {
98 fn default() -> Self {
35 Self {
99 Self {
36− cpu_count: default_cpu_count(),
100+ cpu_count: DEFAULT_CPU_COUNT,
37− ram_mb: default_ram_mb(),
101+ ram_size: ByteSize::mib(DEFAULT_RAM_MB),
38− disk_gb: default_disk_gb(),
102+ disk_size: ByteSize::gib(DEFAULT_DISK_GB),
39 mounts: default_mounts(),
103 mounts: default_mounts(),
40 }
104 }
41 }
105 }
@@ -49,23 +113,11 @@ pub struct SupervisorConfig {
49impl Default for SupervisorConfig {
113impl Default for SupervisorConfig {
50 fn default() -> Self {
114 fn default() -> Self {
51 Self {
115 Self {
52− auto_shutdown_ms: default_auto_shutdown_ms(),
116+ auto_shutdown_ms: DEFAULT_AUTO_SHUTDOWN_MS,
53 }
117 }
54 }
118 }
55}
119}
56
120
57−fn default_cpu_count() -> usize {
58− DEFAULT_CPU_COUNT
59−}
60−
61−fn default_ram_mb() -> u64 {
62− DEFAULT_RAM_MB
63−}
64−
65−fn default_auto_shutdown_ms() -> u64 {
66− DEFAULT_AUTO_SHUTDOWN_MS
67−}
68−
69fn default_mounts() -> Vec<String> {
121fn default_mounts() -> Vec<String> {
70 vec![
122 vec![
71 "~/.codex:~/.codex:read-write".into(),
123 "~/.codex:~/.codex:read-write".into(),
@@ -73,19 +125,12 @@ fn default_mounts() -> Vec<String> {
73 ]
125 ]
74}
126}
75
127
76−fn default_disk_gb() -> u64 {
77− DEFAULT_DISK_GB
78−}
79−
80pub fn config_path(project_root: &Path) -> PathBuf {
128pub fn config_path(project_root: &Path) -> PathBuf {
81 project_root.join(CONFIG_FILENAME)
129 project_root.join(CONFIG_FILENAME)
82}
130}
83
131
84−pub fn ensure_config_file(
132+pub fn ensure_config_file(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
85− project_root: &Path,
86− override_path: Option<&Path>,
87−) -> Result<PathBuf, io::Error> {
88− let path = resolve_config_path(project_root, override_path);
133+ let path = resolve_config_path(project_root, override_path)?;
89 if !path.exists() {
134 if !path.exists() {
90 let default_config = Config::default();
135 let default_config = Config::default();
91 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
136 let contents = toml::to_string_pretty(&default_config).unwrap_or_default();
@@ -95,32 +140,24 @@ pub fn ensure_config_file(
95 Ok(path)
140 Ok(path)
96}
141}
97
142
98−pub fn load_config(project_root: &Path) -> Config {
143+pub fn load_config(project_root: &Path) -> Result<Config> {
99 load_config_with_path(project_root, None)
144 load_config_with_path(project_root, None)
100}
145}
101
146
102−pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Config {
147+pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>) -> Result<Config> {
148+ let path =
103− let path = match ensure_config_file(project_root, override_path) {
149+ ensure_config_file(project_root, override_path).context("failed to create config")?;
104− Ok(path) => path,
105− Err(err) => die(&format!("failed to create config: {err}")),
106− };
107− let raw = match fs::read_to_string(&path) {
150+ let raw = fs::read_to_string(&path).context("failed to read config")?;
108− Ok(raw) => raw,
109− Err(err) => die(&format!("failed to read config: {err}")),
110− };
111 let trimmed = raw.trim();
151 let trimmed = raw.trim();
112 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
152 tracing::debug!(path = %path.display(), bytes = raw.len(), "loaded vibebox config");
113 if trimmed.is_empty() {
153 if trimmed.is_empty() {
114− die(&format!(
154+ bail!(format!(
115 "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
155 "config file ({}) is empty. Required fields: [box].cpu_count (integer), [box].ram_mb (integer), [box].disk_gb (integer), [box].mounts (array of strings), [supervisor].auto_shutdown_ms (integer)",
116 path.display()
156 path.display()
117 ));
157 ));
118 }
158 }
119
159
120− let value: toml::Value = match toml::from_str(trimmed) {
160+ let value: toml::Value = toml::from_str(trimmed).context("invalid config")?;
121− Ok(value) => value,
122− Err(err) => die(&format!("invalid config: {err}")),
123− };
124 let schema_errors = validate_schema(&value);
161 let schema_errors = validate_schema(&value);
125 if !schema_errors.is_empty() {
162 if !schema_errors.is_empty() {
126 let message = format!(
163 let message = format!(
@@ -128,27 +165,28 @@ pub fn load_config_with_path(project_root: &Path, override_path: Option<&Path>)
128 path.display(),
165 path.display(),
129 schema_errors.join("\n- ")
166 schema_errors.join("\n- ")
130 );
167 );
131− die(&message);
168+ bail!(message);
132 }
169 }
133
170
134− let config: Config = match toml::from_str(trimmed) {
171+ let config: Config = toml::from_str(trimmed).context("invalid config")?;
172+ validate_config(&config).map_err(Error::msg)?;
135− Ok(config) => config,
173+ Ok(config)
136− Err(err) => die(&format!("invalid config: {err}")),
137− };
138− validate_or_exit(&config);
139− config
140}
174}
141
175
142−fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> PathBuf {
176+fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Result<PathBuf> {
143− let root = match fs::canonicalize(project_root) {
177+ let env_override = env::var_os(CONFIG_PATH_ENV).map(PathBuf::from);
144− Ok(root) => root,
178+ resolve_config_path_inner(project_root, override_path, env_override)
145− Err(err) => die(&format!("failed to resolve project root: {err}")),
179+}
146− };
147
180
181+fn resolve_config_path_inner(
182+ project_root: &Path,
148− let override_path = override_path
183+ override_path: Option<&Path>,
184+ env_override: Option<PathBuf>,
149− .map(PathBuf::from)
185+) -> Result<PathBuf> {
150− .or_else(|| env::var_os(CONFIG_PATH_ENV).map(PathBuf::from));
186+ let root = fs::canonicalize(project_root).context("failed to resolve project root")?;
187+
188+ let selected_path = override_path.map(PathBuf::from).or(env_override);
151− let raw_path = if let Some(path) = override_path {
189+ let raw_path = if let Some(path) = selected_path {
152 if path.is_absolute() {
190 if path.is_absolute() {
153 path
191 path
154 } else {
192 } else {
@@ -159,14 +197,50 @@ fn resolve_config_path(project_root: &Path, override_path: Option<&Path>) -> Pat
159 };
197 };
160
198
161 let normalized = normalize_path(&raw_path);
199 let normalized = normalize_path(&raw_path);
200+ let resolved =
201+ resolve_path_for_boundary_check(&normalized).context("failed to resolve config path")?;
162− if !normalized.starts_with(&root) {
202+ if !resolved.starts_with(&root) {
163− die(&format!(
203+ bail!(
164 "config path must be within {}: {}",
204 "config path must be within {}: {}",
165 root.display(),
205 root.display(),
166− normalized.display()
206+ resolved.display()
167− ));
207+ );
168 }
208 }
169− normalized
209+ Ok(normalized)
210+}
211+
212+fn resolve_path_for_boundary_check(path: &Path) -> Result<PathBuf, io::Error> {
213+ if path.exists() {
214+ return fs::canonicalize(path);
215+ }
216+ let (ancestor, missing) = nearest_existing_ancestor(path)?;
217+ let mut resolved = fs::canonicalize(ancestor)?;
218+ for part in missing {
219+ resolved.push(part);
220+ }
221+ Ok(resolved)
222+}
223+
224+fn nearest_existing_ancestor(path: &Path) -> Result<(&Path, Vec<std::ffi::OsString>), io::Error> {
225+ let mut current = path;
226+ let mut missing = Vec::new();
227+ while !current.exists() {
228+ let name = current.file_name().ok_or_else(|| {
229+ io::Error::new(
230+ io::ErrorKind::InvalidInput,
231+ format!("path has no existing ancestor: {}", path.display()),
232+ )
233+ })?;
234+ missing.push(name.to_os_string());
235+ current = current.parent().ok_or_else(|| {
236+ io::Error::new(
237+ io::ErrorKind::InvalidInput,
238+ format!("path has no parent: {}", path.display()),
239+ )
240+ })?;
241+ }
242+ missing.reverse();
243+ Ok((current, missing))
170}
244}
171
245
172fn normalize_path(path: &Path) -> PathBuf {
246fn normalize_path(path: &Path) -> PathBuf {
@@ -263,27 +337,343 @@ fn validate_string_array(
263 }
337 }
264}
338}
265
339
266−fn validate_or_exit(config: &Config) {
340+fn validate_config(config: &Config) -> Result<(), String> {
267 if config.box_cfg.cpu_count == 0 {
341 if config.box_cfg.cpu_count == 0 {
268− die("box.cpu_count must be >= 1");
342+ return Err("box.cpu_count must be >= 1".to_string());
269 }
343 }
270− if config.box_cfg.ram_mb == 0 {
344+ if config.box_cfg.ram_size.as_mib() == 0.0 {
271− die("box.ram_mb must be >= 1");
345+ return Err("box.ram_mb must be >= 1".to_string());
272 }
346 }
273− if config.box_cfg.disk_gb == 0 {
347+ if config.box_cfg.disk_size.as_gib() == 0.0 {
274− die("box.disk_gb must be >= 1");
348+ return Err("box.disk_gb must be >= 1".to_string());
275 }
349 }
276 if config.supervisor.auto_shutdown_ms == 0 {
350 if config.supervisor.auto_shutdown_ms == 0 {
277− die("supervisor.auto_shutdown_ms must be >= 1");
351+ return Err("supervisor.auto_shutdown_ms must be >= 1".to_string());
278 }
352 }
279 for spec in &config.box_cfg.mounts {
353 for spec in &config.box_cfg.mounts {
280 if let Err(err) = DirectoryShare::from_mount_spec(spec) {
354 if let Err(err) = DirectoryShare::from_mount_spec(spec) {
281− die(&format!("invalid mount spec '{spec}': {err}"));
355+ return Err(format!("invalid mount spec '{spec}': {err}"));
282 }
356 }
283 }
357 }
358+ Ok(())
284}
359}
285
360
286−fn die(message: &str) -> ! {
361+#[cfg(test)]
287− tracing::error!("{message}");
362+mod tests {
288− std::process::exit(1);
363+ use super::*;
364+ use std::ffi::OsString;
365+ use std::sync::Mutex;
366+ use tempfile::TempDir;
367+
368+ static ENV_MUTEX: Mutex<()> = Mutex::new(());
369+
370+ struct EnvGuard {
371+ key: &'static str,
372+ previous: Option<OsString>,
373+ }
374+
375+ impl EnvGuard {
376+ fn set(key: &'static str, value: &Path) -> Self {
377+ let previous = env::var_os(key);
378+ unsafe {
379+ env::set_var(key, value);
380+ }
381+ Self { key, previous }
382+ }
383+ }
384+
385+ impl Drop for EnvGuard {
386+ fn drop(&mut self) {
387+ match &self.previous {
388+ Some(value) => unsafe {
389+ env::set_var(self.key, value);
390+ },
391+ None => unsafe {
392+ env::remove_var(self.key);
393+ },
394+ }
395+ }
396+ }
397+
398+ #[test]
399+ fn default_config_serializes_with_legacy_keys() {
400+ let cfg = Config::default();
401+ let serialized = toml::to_string(&cfg).expect("default config should serialize");
402+
403+ assert!(serialized.contains("ram_mb = 2048"));
404+ assert!(serialized.contains("disk_gb = 5"));
405+ assert!(!serialized.contains("ram_size"));
406+ assert!(!serialized.contains("disk_size"));
407+ }
408+
409+ #[test]
410+ fn config_deserializes_sizes_from_mb_and_gb() {
411+ let raw = r#"
412+[box]
413+cpu_count = 4
414+ram_mb = 3072
415+disk_gb = 12
416+mounts = ["~/src:~/src:read-write"]
417+
418+[supervisor]
419+auto_shutdown_ms = 15000
420+"#;
421+ let cfg: Config = toml::from_str(raw).expect("config should deserialize");
422+
423+ assert_eq!(cfg.box_cfg.cpu_count, 4);
424+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(3072).as_u64());
425+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(12).as_u64());
426+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 15000);
427+ }
428+
429+ #[test]
430+ fn serialize_rejects_non_integral_mb_or_gb() {
431+ let cfg = Config {
432+ box_cfg: BoxConfig {
433+ cpu_count: 2,
434+ ram_size: ByteSize::b((2 * MI_B) + 1),
435+ disk_size: ByteSize::gib(5),
436+ mounts: default_mounts(),
437+ },
438+ supervisor: SupervisorConfig::default(),
439+ };
440+
441+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid MB");
442+ assert!(
443+ err.to_string()
444+ .contains("ram_mb must be an integer number of MB")
445+ );
446+
447+ let cfg = Config {
448+ box_cfg: BoxConfig {
449+ cpu_count: 2,
450+ ram_size: ByteSize::mib(2048),
451+ disk_size: ByteSize::b((5 * GI_B) + 1),
452+ mounts: default_mounts(),
453+ },
454+ supervisor: SupervisorConfig::default(),
455+ };
456+
457+ let err = toml::to_string(&cfg).expect_err("serialization should reject invalid GB");
458+ assert!(
459+ err.to_string()
460+ .contains("disk_gb must be an integer number of GB")
461+ );
462+ }
463+
464+ #[test]
465+ fn normalize_path_removes_dot_and_parent_components() {
466+ let normalized = normalize_path(Path::new("/tmp/project/./nested/../config.toml"));
467+ assert_eq!(normalized, PathBuf::from("/tmp/project/config.toml"));
468+ }
469+
470+ #[test]
471+ fn validate_schema_returns_errors_for_missing_required_fields() {
472+ let value: toml::Value = toml::from_str(
473+ r#"
474+[box]
475+cpu_count = 2
476+"#,
477+ )
478+ .expect("toml should parse");
479+
480+ let errors = validate_schema(&value);
481+
482+ assert!(errors.iter().any(|e| e == "missing [supervisor] table"));
483+ assert!(errors.iter().any(|e| e == "missing [box].ram_mb (integer)"));
484+ assert!(
485+ errors
486+ .iter()
487+ .any(|e| e == "missing [box].disk_gb (integer)")
488+ );
489+ assert!(
490+ errors
491+ .iter()
492+ .any(|e| e == "missing [box].mounts (array of strings)")
493+ );
494+ }
495+
496+ #[test]
497+ fn validate_schema_errors_when_supervisor_is_not_table() {
498+ let value: toml::Value = toml::from_str(
499+ r#"
500+supervisor = 123
501+
502+[box]
503+cpu_count = 2
504+ram_mb = 2048
505+disk_gb = 5
506+mounts = []
507+"#,
508+ )
509+ .expect("toml should parse");
510+
511+ let errors = validate_schema(&value);
512+ assert!(errors.iter().any(|e| e == "[supervisor] must be a table"));
513+ }
514+
515+ #[test]
516+ fn ensure_config_file_creates_default_config_if_absent() {
517+ let temp = TempDir::new().expect("temp dir should be created");
518+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
519+
520+ let path = ensure_config_file(&root, None).expect("config should be created");
521+ let raw = fs::read_to_string(&path).expect("created config should be readable");
522+ let parsed: Config = toml::from_str(&raw).expect("created config should be valid");
523+
524+ assert_eq!(path, root.join("vibebox.toml"));
525+ assert_eq!(parsed.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
526+ assert_eq!(
527+ parsed.box_cfg.ram_size.as_u64(),
528+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
529+ );
530+ assert_eq!(
531+ parsed.box_cfg.disk_size.as_u64(),
532+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
533+ );
534+ }
535+
536+ #[test]
537+ fn load_config_creates_and_loads_default_config() {
538+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
539+ let temp = TempDir::new().expect("temp dir should be created");
540+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
541+ let home = root.join("home");
542+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
543+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
544+ let _home_guard = EnvGuard::set("HOME", &home);
545+
546+ let cfg = load_config(&root).expect("load_config should succeed");
547+
548+ assert_eq!(cfg.box_cfg.cpu_count, DEFAULT_CPU_COUNT);
549+ assert_eq!(
550+ cfg.box_cfg.ram_size.as_u64(),
551+ ByteSize::mib(DEFAULT_RAM_MB).as_u64()
552+ );
553+ assert_eq!(
554+ cfg.box_cfg.disk_size.as_u64(),
555+ ByteSize::gib(DEFAULT_DISK_GB).as_u64()
556+ );
557+ assert!(root.join("vibebox.toml").exists());
558+ }
559+
560+ #[test]
561+ fn load_config_with_path_uses_override_path() {
562+ let _lock = ENV_MUTEX.lock().expect("env lock should be acquired");
563+ let temp = TempDir::new().expect("temp dir should be created");
564+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
565+ let home = root.join("home");
566+ fs::create_dir_all(home.join(".codex")).expect("home .codex should be created");
567+ fs::create_dir_all(home.join(".claude")).expect("home .claude should be created");
568+ let _home_guard = EnvGuard::set("HOME", &home);
569+ let override_path = root.join("custom.toml");
570+
571+ fs::write(
572+ &override_path,
573+ r#"
574+[box]
575+cpu_count = 6
576+ram_mb = 4096
577+disk_gb = 9
578+mounts = ["~/.codex:~/.codex:read-write", "~/.claude:~/.claude:read-write"]
579+
580+[supervisor]
581+auto_shutdown_ms = 12345
582+"#,
583+ )
584+ .expect("override config should be written");
585+
586+ let cfg = load_config_with_path(&root, Some(Path::new("custom.toml")))
587+ .expect("load_config_with_path should succeed");
588+
589+ assert_eq!(cfg.box_cfg.cpu_count, 6);
590+ assert_eq!(cfg.box_cfg.ram_size.as_u64(), ByteSize::mib(4096).as_u64());
591+ assert_eq!(cfg.box_cfg.disk_size.as_u64(), ByteSize::gib(9).as_u64());
592+ assert_eq!(cfg.supervisor.auto_shutdown_ms, 12345);
593+ assert!(!root.join("vibebox.toml").exists());
594+ }
595+
596+ #[test]
597+ fn resolve_config_path_uses_env_override_when_cli_override_missing() {
598+ let temp = TempDir::new().expect("temp dir should be created");
599+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
600+
601+ let resolved = resolve_config_path_inner(&root, None, Some(PathBuf::from("custom.toml")))
602+ .expect("env override path should resolve");
603+
604+ assert_eq!(resolved, root.join("custom.toml"));
605+ }
606+
607+ #[test]
608+ fn resolve_config_path_rejects_env_override_outside_project() {
609+ let temp = TempDir::new().expect("temp dir should be created");
610+ let root = fs::canonicalize(temp.path()).expect("temp dir should canonicalize");
611+
612+ let err = resolve_config_path_inner(&root, None, Some(PathBuf::from("../escape.toml")))
613+ .expect_err("outside-project path should be rejected");
614+
615+ assert!(
616+ err.to_string().contains("config path must be within"),
617+ "expected bounds-check error, got: {err}"
618+ );
619+ }
620+
621+ #[test]
622+ fn validate_config_rejects_invalid_values() {
623+ let cfg = Config {
624+ box_cfg: BoxConfig {
625+ cpu_count: 0,
626+ ram_size: ByteSize::mib(2048),
627+ disk_size: ByteSize::gib(5),
628+ mounts: vec![],
629+ },
630+ supervisor: SupervisorConfig::default(),
631+ };
632+ let err = validate_config(&cfg).expect_err("cpu_count=0 should fail");
633+ assert_eq!(err, "box.cpu_count must be >= 1");
634+
635+ let cfg = Config {
636+ box_cfg: BoxConfig {
637+ cpu_count: 2,
638+ ram_size: ByteSize::mib(2048),
639+ disk_size: ByteSize::gib(5),
640+ mounts: vec!["/definitely/missing:/tmp/missing:read-write".to_string()],
641+ },
642+ supervisor: SupervisorConfig::default(),
643+ };
644+ let err = validate_config(&cfg).expect_err("invalid mount should fail");
645+ assert!(err.starts_with("invalid mount spec"));
646+ }
647+
648+ #[test]
649+ fn resolve_config_path_accepts_symlinked_project_root() {
650+ let temp = TempDir::new().expect("temp dir should be created");
651+ let actual_root = temp.path().join("actual");
652+ let link_root = temp.path().join("linked");
653+ fs::create_dir_all(&actual_root).expect("actual root should exist");
654+ std::os::unix::fs::symlink(&actual_root, &link_root).expect("symlink should be created");
655+
656+ let resolved = resolve_config_path_inner(&link_root, Some(Path::new("vibebox.toml")), None)
657+ .expect("symlinked project root should resolve");
658+
659+ assert_eq!(resolved, link_root.join("vibebox.toml"));
660+ }
661+
662+ #[test]
663+ fn resolve_config_path_rejects_symlink_escape() {
664+ let temp = TempDir::new().expect("temp dir should be created");
665+ let project_root = temp.path().join("project");
666+ let outside_root = temp.path().join("outside");
667+ fs::create_dir_all(&project_root).expect("project root should exist");
668+ fs::create_dir_all(&outside_root).expect("outside root should exist");
669+ std::os::unix::fs::symlink(&outside_root, project_root.join("link"))
670+ .expect("escape symlink should be created");
671+
672+ let err = resolve_config_path_inner(&project_root, Some(Path::new("link/cfg.toml")), None)
673+ .expect_err("symlink escape should be rejected");
674+ assert!(
675+ err.to_string().contains("config path must be within"),
676+ "expected bounds-check error, got: {err}"
677+ );
678+ }
289}
679}
src/error_report.shnew file+28
@@ -0,0 +1,28 @@
1+#!/usr/bin/env bash
2+set -Eeuo pipefail
3+__vibebox_err_reported=0
4+__vibebox_report_error() {
5+ local rc="$1"
6+ local line="$2"
7+ local msg="${3:-}"
8+ if [ "$__vibebox_err_reported" -eq 0 ]; then
9+ msg="${msg//$'\n'/ }"
10+ msg="${msg//$'\r'/ }"
11+ if [ -n "$msg" ]; then
12+ echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc} ${msg}"
13+ else
14+ echo "VIBEBOX_SCRIPT_ERROR:__LABEL__:${line}:${rc}"
15+ fi
16+ __vibebox_err_reported=1
17+ fi
18+}
19+vibebox_fail() {
20+ local msg="${1:-script failed}"
21+ local rc="${2:-1}"
22+ __vibebox_report_error "$rc" "${LINENO}" "$msg"
23+ exit "$rc"
24+}
25+trap 'rc="$?"; __vibebox_report_error "$rc" "${LINENO}" "command failed: ${BASH_COMMAND:-unknown}"' ERR
26+trap 'rc="$?"; if [ "$rc" -ne 0 ]; then __vibebox_report_error "$rc" "${LINENO}" "script exited with code ${rc}"; fi' EXIT
27+
28+__SCRIPT_BODY__
src/explain.rs+32−47
@@ -1,31 +1,31 @@
1+use crate::instance::InstanceConfig;
2+use crate::utils::relative_to_home;
3+use crate::{config, instance, session_manager, tui};
4+use anyhow::{Context, Result, bail};
1use std::{
5use std::{
2 env,
6 env,
3− error::Error,
4 path::{Path, PathBuf},
7 path::{Path, PathBuf},
5};
8};
6
9
7−use crate::{config, instance, session_manager, tui};
8−
9−pub fn build_mount_rows(
10+pub fn build_mount_rows(project: &Path, config: &config::Config) -> Result<Vec<tui::MountListRow>> {
10− cwd: &Path,
11− config: &config::Config,
12−) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
13 let mut rows = Vec::new();
11 let mut rows = Vec::new();
14− rows.extend(default_mounts(cwd)?);
12+ rows.extend(default_mounts(project)?);
15− let guest_home = resolve_guest_home(cwd)?;
13+ let guest_home = resolve_guest_home(project);
16 for spec in &config.box_cfg.mounts {
14 for spec in &config.box_cfg.mounts {
17− rows.push(parse_mount_spec(cwd, spec, false, &guest_home)?);
15+ rows.push(parse_mount_spec(project, spec, false, &guest_home)?);
18 }
16 }
19 Ok(rows)
17 Ok(rows)
20}
18}
21
19
22−pub fn build_network_rows(
20+pub fn build_network_rows(project_dir: &Path) -> Result<Vec<tui::NetworkListRow>> {
23− cwd: &Path,
24−) -> Result<Vec<tui::NetworkListRow>, Box<dyn Error + Send + Sync>> {
25− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
26 let mut vm_ip = "-".to_string();
21 let mut vm_ip = "-".to_string();
27− if let Ok(Some(ip)) = instance::read_instance_vm_ip(&instance_dir) {
22+ if let Ok(config) = instance::read_instance_config(project_dir) {
23+ match config.vm_ipv4 {
24+ None => {}
25+ Some(ip) => {
28− vm_ip = ip;
26+ vm_ip = ip;
27+ }
28+ }
29 }
29 }
30 let host_to_vm = if vm_ip == "-" {
30 let host_to_vm = if vm_ip == "-" {
31 "ssh: <pending>:22".to_string()
31 "ssh: <pending>:22".to_string()
@@ -41,13 +41,13 @@ pub fn build_network_rows(
41 Ok(vec![row])
41 Ok(vec![row])
42}
42}
43
43
44−fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error + Send + Sync>> {
44+fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>> {
45 let project_name = cwd
45 let project_name = cwd
46 .file_name()
46 .file_name()
47 .and_then(|name| name.to_str())
47 .and_then(|name| name.to_str())
48− .unwrap_or("project");
48+ .with_context(|| "failed to get project name")?;
49 let project_guest = format!("~/{project_name}");
49 let project_guest = format!("~/{project_name}");
50− let project_host = display_path(cwd);
50+ let project_host = relative_to_home(cwd);
51 let mut rows = vec![tui::MountListRow {
51 let mut rows = vec![tui::MountListRow {
52 host: project_host,
52 host: project_host,
53 guest: project_guest,
53 guest: project_guest,
@@ -57,14 +57,14 @@ fn default_mounts(cwd: &Path) -> Result<Vec<tui::MountListRow>, Box<dyn Error +
57
57
58 let home = env::var("HOME")
58 let home = env::var("HOME")
59 .map(PathBuf::from)
59 .map(PathBuf::from)
60− .unwrap_or_else(|_| PathBuf::from("/"));
60+ .with_context(|| "failed to get home directory")?;
61 let cache_home = env::var("XDG_CACHE_HOME")
61 let cache_home = env::var("XDG_CACHE_HOME")
62 .map(PathBuf::from)
62 .map(PathBuf::from)
63 .unwrap_or_else(|_| home.join(".cache"));
63 .unwrap_or_else(|_| home.join(".cache"));
64 let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
64 let cache_dir = cache_home.join(session_manager::GLOBAL_CACHE_DIR_NAME);
65 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
65 let guest_mise_cache = cache_dir.join(".guest-mise-cache");
66 rows.push(tui::MountListRow {
66 rows.push(tui::MountListRow {
67− host: display_path(&guest_mise_cache),
67+ host: relative_to_home(&guest_mise_cache),
68 guest: "/root/.local/share/mise".to_string(),
68 guest: "/root/.local/share/mise".to_string(),
69 mode: "read-write".to_string(),
69 mode: "read-write".to_string(),
70 default_mount: "yes".to_string(),
70 default_mount: "yes".to_string(),
@@ -77,10 +77,10 @@ fn parse_mount_spec(
77 spec: &str,
77 spec: &str,
78 default_mount: bool,
78 default_mount: bool,
79 guest_home: &str,
79 guest_home: &str,
80−) -> Result<tui::MountListRow, Box<dyn Error + Send + Sync>> {
80+) -> Result<tui::MountListRow> {
81 let parts: Vec<&str> = spec.split(':').collect();
81 let parts: Vec<&str> = spec.split(':').collect();
82 if parts.len() < 2 || parts.len() > 3 {
82 if parts.len() < 2 || parts.len() > 3 {
83− return Err(format!("invalid mount spec: {spec}").into());
83+ bail!["invalid mount spec: {spec}"];
84 }
84 }
85 let host_part = parts[0];
85 let host_part = parts[0];
86 let guest_part = parts[1];
86 let guest_part = parts[1];
@@ -89,11 +89,10 @@ fn parse_mount_spec(
89 "read-only" => "read-only",
89 "read-only" => "read-only",
90 "read-write" => "read-write",
90 "read-write" => "read-write",
91 other => {
91 other => {
92− return Err(format!(
92+ bail![format!(
93 "invalid mount mode '{}'; expected read-only or read-write",
93 "invalid mount mode '{}'; expected read-only or read-write",
94 other
94 other
95− )
95+ )];
96− .into());
97 }
96 }
98 }
97 }
99 } else {
98 } else {
@@ -116,22 +115,22 @@ fn display_host_spec(cwd: &Path, host: &str) -> String {
116 }
115 }
117 let host_path = PathBuf::from(host);
116 let host_path = PathBuf::from(host);
118 if host_path.is_absolute() {
117 if host_path.is_absolute() {
119− return display_path(&host_path);
118+ return relative_to_home(&host_path);
120 }
119 }
121 let candidate = cwd.join(&host_path);
120 let candidate = cwd.join(&host_path);
122 if candidate.is_absolute() {
121 if candidate.is_absolute() {
123− display_path(&candidate)
122+ relative_to_home(&candidate)
124 } else {
123 } else {
125 host.to_string()
124 host.to_string()
126 }
125 }
127}
126}
128
127
129−fn resolve_guest_home(cwd: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
128+fn resolve_guest_home(project_dir: &Path) -> String {
130− let instance_dir = cwd.join(session_manager::INSTANCE_DIR_NAME);
129+ let config = instance::read_instance_config(project_dir);
131− if let Ok(Some(user)) = instance::read_instance_ssh_user(&instance_dir) {
130+ match config {
132− return Ok(format!("/home/{user}"));
131+ Ok(config) => format!("/home/{}", config.ssh_user),
132+ Err(_) => format!("/home/{}", InstanceConfig::default().ssh_user),
133 }
133 }
134− Ok(format!("/home/{}", instance::DEFAULT_SSH_USER))
135}
134}
136
135
137fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
136fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
@@ -155,17 +154,3 @@ fn resolve_guest_display(guest: &str, guest_home: &str) -> String {
155 format!("/root/{guest}")
154 format!("/root/{guest}")
156 }
155 }
157}
156}
158−
159−fn display_path(path: &Path) -> String {
160− let Ok(home) = env::var("HOME") else {
161− return path.display().to_string();
162− };
163− let home_path = PathBuf::from(home);
164− if let Ok(stripped) = path.strip_prefix(&home_path) {
165− if stripped.components().next().is_none() {
166− return "~".to_string();
167− }
168− return format!("~/{}", stripped.display());
169− }
170− path.display().to_string()
171−}
src/instance.rs+289−154
@@ -1,82 +1,140 @@
1+use anyhow::{Context, Result, bail};
2+use serde::{Deserialize, Serialize};
3+use std::os::unix::fs::FileTypeExt;
1use std::{
4use std::{
2 env, fs,
5 env, fs,
3− io::{self},
6+ io::{self, IsTerminal, Read},
4 net::{SocketAddr, TcpStream},
7 net::{SocketAddr, TcpStream},
5 os::unix::{fs::PermissionsExt, net::UnixStream},
8 os::unix::{fs::PermissionsExt, net::UnixStream},
6 path::{Path, PathBuf},
9 path::{Path, PathBuf},
7− process::{Command, Stdio},
10+ process::{Child, Command, Stdio},
11+ sync::{
8− sync::{Arc, Mutex},
12+ Arc, Mutex,
13+ atomic::{AtomicBool, Ordering},
14+ mpsc,
15+ },
9 thread,
16 thread,
10 time::{Duration, Instant},
17 time::{Duration, Instant},
11};
18};
12−
13−use serde::{Deserialize, Serialize};
14use time::{OffsetDateTime, format_description::well_known::Rfc3339};
19use time::{OffsetDateTime, format_description::well_known::Rfc3339};
15use uuid::Uuid;
20use uuid::Uuid;
16
21
17use crate::{
22use crate::{
18 commands,
23 commands,
19− session_manager::{INSTANCE_DIR_NAME, INSTANCE_FILENAME},
24+ session_manager::INSTANCE_DIR_NAME,
25+ session_manager::{VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
20 vm::{self, LoginAction},
26 vm::{self, LoginAction},
21};
27};
22
28
29+pub const STATUS_VM_ERROR_PREFIX: &str = "error:";
30+
23const SSH_KEY_NAME: &str = "ssh_key";
31const SSH_KEY_NAME: &str = "ssh_key";
24−#[cfg_attr(feature = "mock-vm", allow(dead_code))]
32+const INSTANCE_FILENAME: &str = "instance.toml";
25−pub(crate) const VM_ROOT_LOG_NAME: &str = "vm_root.log";
26−pub(crate) const STATUS_FILE_NAME: &str = "status.txt";
27−pub(crate) const DEFAULT_SSH_USER: &str = "vibecoder";
33+const DEFAULT_SSH_USER: &str = "vibecoder";
28−const SSH_CONNECT_RETRIES: usize = 30;
34+const SSH_CONNECT_RETRIES: usize = 10;
29const SSH_CONNECT_DELAY_MS: u64 = 500;
35const SSH_CONNECT_DELAY_MS: u64 = 500;
30const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
36const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
37+const STATUS_PREFIX: &str = "status:";
38+
39+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40+pub enum VmLiveness {
41+ RunningWithSocket { pid: u32 },
42+ RunningWithoutSocket { pid: u32 },
43+ NotRunningOrMissing,
44+}
45+
46+pub fn vm_liveness(project_root: &Path) -> Result<VmLiveness> {
47+ let instance_dir = ensure_instance_dir(project_root)?;
48+ let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
49+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
50+ fn pid_is_alive(pid: u32) -> bool {
51+ let pid = pid as libc::pid_t;
52+ let result = unsafe { libc::kill(pid, 0) };
53+ if result == 0 {
54+ return true;
55+ }
56+ match io::Error::last_os_error().raw_os_error() {
57+ Some(code) if code == libc::EPERM => true,
58+ Some(code) if code == libc::ESRCH => false,
59+ _ => false,
60+ }
61+ }
62+ let Ok(content) = fs::read_to_string(pid_path) else {
63+ return Ok(VmLiveness::NotRunningOrMissing);
64+ };
65+ let Ok(pid) = content.trim().parse::<u32>() else {
66+ return Ok(VmLiveness::NotRunningOrMissing);
67+ };
68+ if !pid_is_alive(pid) {
69+ return Ok(VmLiveness::NotRunningOrMissing);
70+ }
71+ let has_socket = fs::metadata(socket_path)
72+ .map(|meta| meta.file_type().is_socket())
73+ .unwrap_or(false);
74+ if has_socket {
75+ Ok(VmLiveness::RunningWithSocket { pid })
76+ } else {
77+ Ok(VmLiveness::RunningWithoutSocket { pid })
78+ }
79+}
80+
81+#[derive(Debug, thiserror::Error)]
82+pub enum InstanceError {
83+ #[error("unexpected disconnection from vm manager")]
84+ UnexpectedDisconnection,
85+ #[error("{0}")]
86+ VMError(String),
87+}
88+
89+fn default_ssh_user() -> String {
90+ DEFAULT_SSH_USER.to_string()
91+}
31
92
32#[derive(Debug, Clone, Serialize, Deserialize)]
93#[derive(Debug, Clone, Serialize, Deserialize)]
33−pub(crate) struct InstanceConfig {
94+pub struct InstanceConfig {
34 #[serde(default)]
95 #[serde(default)]
35− id: String,
96+ pub id: String,
36 #[serde(default = "default_ssh_user")]
97 #[serde(default = "default_ssh_user")]
37− ssh_user: String,
98+ pub ssh_user: String,
38 #[serde(default)]
99 #[serde(default)]
39 sudo_password: String,
100 sudo_password: String,
40 #[serde(default)]
101 #[serde(default)]
41− last_active: Option<String>,
102+ pub last_active: Option<String>,
42 #[serde(default)]
103 #[serde(default)]
43− pub(crate) vm_ipv4: Option<String>,
104+ pub vm_ipv4: Option<String>,
44}
105}
45
106
46−impl InstanceConfig {
107+impl Default for InstanceConfig {
47− pub(crate) fn ssh_user_display(&self) -> String {
108+ fn default() -> Self {
48− if self.ssh_user.trim().is_empty() {
109+ Self {
110+ id: Uuid::now_v7().to_string(),
49− DEFAULT_SSH_USER.to_string()
111+ ssh_user: DEFAULT_SSH_USER.to_string(),
50− } else {
112+ sudo_password: Uuid::now_v7().simple().to_string(),
51− self.ssh_user.clone()
113+ last_active: None,
114+ vm_ipv4: None,
52 }
115 }
53 }
116 }
54}
117}
55
118
56−fn default_ssh_user() -> String {
57− DEFAULT_SSH_USER.to_string()
58−}
59−
60−pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
119+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<()> {
61 let project_root = env::current_dir()?;
120 let project_root = env::current_dir()?;
62 tracing::info!(root = %project_root.display(), "starting ssh session");
121 tracing::info!(root = %project_root.display(), "starting ssh session");
63 let instance_dir = ensure_instance_dir(&project_root)?;
122 let instance_dir = ensure_instance_dir(&project_root)?;
64 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
123 tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
65 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
124 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
66
125
67− let config = load_or_create_instance_config(&instance_dir)?;
126+ let config = load_or_create_instance_config(&project_root)?;
68 let ssh_user = config.ssh_user.clone();
127 let ssh_user = config.ssh_user.clone();
69 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
128 tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
70
129
71− let _manager_conn = manager_conn;
72− wait_for_vm_ipv4(&instance_dir, Duration::from_secs(480))?;
130+ wait_for_vm_ipv4(&project_root, Duration::from_secs(480), &manager_conn)?;
73
131
74− let ip = load_or_create_instance_config(&instance_dir)?
132+ let ip = load_or_create_instance_config(&project_root)?
75 .vm_ipv4
133 .vm_ipv4
76− .ok_or("VM IPv4 not available")?;
134+ .with_context(|| "failed to load instance IP address")?;
77 tracing::info!(ip = %ip, "vm ipv4 ready");
135 tracing::info!(ip = %ip, "vm ipv4 ready");
78
136
79− run_ssh_session(ssh_key, ssh_user, ip)
137+ run_ssh_session(ssh_key, ssh_user, ip, manager_conn, project_root)
80}
138}
81
139
82pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
140pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
@@ -85,9 +143,7 @@ pub fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
85 Ok(instance_dir)
143 Ok(instance_dir)
86}
144}
87
145
88−pub(crate) fn ensure_ssh_keypair(
146+pub fn ensure_ssh_keypair(instance_dir: &Path) -> Result<(PathBuf, PathBuf)> {
89− instance_dir: &Path,
90−) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
91 let private_key = instance_dir.join(SSH_KEY_NAME);
147 let private_key = instance_dir.join(SSH_KEY_NAME);
92 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
148 let public_key = instance_dir.join(format!("{SSH_KEY_NAME}.pub"));
93
149
@@ -109,7 +165,9 @@ pub(crate) fn ensure_ssh_keypair(
109 "-N",
165 "-N",
110 "",
166 "",
111 "-f",
167 "-f",
168+ private_key
169+ .to_str()
112− private_key.to_str().ok_or("ssh key path not utf-8")?,
170+ .with_context(|| "ssh key path not utf-8")?,
113 "-C",
171 "-C",
114 "vibebox",
172 "vibebox",
115 ])
173 ])
@@ -119,7 +177,7 @@ pub(crate) fn ensure_ssh_keypair(
119 .status()?;
177 .status()?;
120
178
121 if !status.success() {
179 if !status.success() {
122− return Err("ssh-keygen failed".into());
180+ bail!("ssh-keygen failed");
123 }
181 }
124
182
125 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
183 fs::set_permissions(&private_key, fs::Permissions::from_mode(0o600))?;
@@ -128,98 +186,66 @@ pub(crate) fn ensure_ssh_keypair(
128 Ok((private_key, public_key))
186 Ok((private_key, public_key))
129}
187}
130
188
131−pub(crate) fn load_or_create_instance_config(
189+pub fn load_or_create_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
132− instance_dir: &Path,
190+ let mut exist = true;
133−) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
191+ let mut config = read_instance_config(project_dir).unwrap_or_else(|_| {
134− let config_path = instance_dir.join(INSTANCE_FILENAME);
192+ exist = false;
135− let mut config = if config_path.exists() {
136− let raw = fs::read_to_string(&config_path)?;
137− toml::from_str::<InstanceConfig>(&raw)?
138− } else {
139− InstanceConfig {
193+ InstanceConfig::default()
140− id: String::new(),
141− ssh_user: default_ssh_user(),
142− sudo_password: String::new(),
143− last_active: None,
144− vm_ipv4: None,
145− }
146− };
194+ });
147
195
148 let mut changed = false;
196 let mut changed = false;
149 if config.ssh_user.trim().is_empty() {
197 if config.ssh_user.trim().is_empty() {
150− config.ssh_user = default_ssh_user();
198+ config.ssh_user = InstanceConfig::default().ssh_user;
151 changed = true;
199 changed = true;
152 }
200 }
153
201
154 if config.id.trim().is_empty() {
202 if config.id.trim().is_empty() {
155− config.id = Uuid::now_v7().to_string();
203+ config.id = InstanceConfig::default().id;
156 changed = true;
204 changed = true;
157 }
205 }
158
206
159 if config.sudo_password.trim().is_empty() {
207 if config.sudo_password.trim().is_empty() {
160− config.sudo_password = generate_password();
208+ config.sudo_password = InstanceConfig::default().sudo_password;
161 changed = true;
209 changed = true;
162 }
210 }
163
211
164− if !config_path.exists() || changed {
212+ if !exist || changed {
165− write_instance_config(&config_path, &config)?;
213+ write_instance_config(project_dir, &config)?;
166 }
214 }
167
215
168 Ok(config)
216 Ok(config)
169}
217}
170
218
171−fn read_instance_config(
219+pub fn read_instance_config(project_dir: &Path) -> Result<InstanceConfig> {
172− instance_dir: &Path,
220+ // todo maybe verify schema?
173−) -> Result<Option<InstanceConfig>, Box<dyn std::error::Error>> {
174− let config_path = instance_dir.join(INSTANCE_FILENAME);
221+ let config_path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
175 if !config_path.exists() {
222 if !config_path.exists() {
176− return Ok(None);
223+ bail!("instance config file does not exist");
177 }
224 }
178 let raw = fs::read_to_string(&config_path)?;
225 let raw = fs::read_to_string(&config_path)?;
179 let config = toml::from_str::<InstanceConfig>(&raw)?;
226 let config = toml::from_str::<InstanceConfig>(&raw)?;
180− Ok(Some(config))
227+ Ok(config)
181−}
182−
183−pub fn read_instance_vm_ip(
184− instance_dir: &Path,
185−) -> Result<Option<String>, Box<dyn std::error::Error>> {
186− let config = read_instance_config(instance_dir)?;
187− Ok(config.and_then(|cfg| cfg.vm_ipv4))
188−}
189−
190−pub fn read_instance_ssh_user(
191− instance_dir: &Path,
192−) -> Result<Option<String>, Box<dyn std::error::Error>> {
193− let config = read_instance_config(instance_dir)?;
194− Ok(config
195− .map(|cfg| cfg.ssh_user)
196− .filter(|user| !user.trim().is_empty()))
197}
228}
198
229
199−pub fn touch_last_active(instance_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
230+pub fn touch_last_active(project_dir: &Path) -> Result<()> {
200− let mut config = load_or_create_instance_config(instance_dir)?;
231+ let mut config = load_or_create_instance_config(project_dir)?;
201 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
232 let now = OffsetDateTime::now_utc().format(&Rfc3339)?;
202 config.last_active = Some(now);
233 config.last_active = Some(now);
203− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
234+ write_instance_config(project_dir, &config)?;
204 Ok(())
235 Ok(())
205}
236}
206
237
207−pub(crate) fn write_instance_config(
238+pub fn write_instance_config(project_dir: &Path, config: &InstanceConfig) -> Result<()> {
208− path: &Path,
239+ let path = project_dir.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
209− config: &InstanceConfig,
210−) -> Result<(), Box<dyn std::error::Error>> {
211 let data = toml::to_string_pretty(config)?;
240 let data = toml::to_string_pretty(config)?;
241+ fs::create_dir_all(project_dir.join(INSTANCE_DIR_NAME))?;
212− fs::write(path, data)?;
242+ fs::write(&path, data)?;
213− fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
243+ fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
214 Ok(())
244 Ok(())
215}
245}
216
246
217−fn generate_password() -> String {
218− Uuid::now_v7().simple().to_string()
219−}
220−
221#[cfg_attr(feature = "mock-vm", allow(dead_code))]
247#[cfg_attr(feature = "mock-vm", allow(dead_code))]
222−pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
248+pub fn extract_ipv4(line: &str) -> Option<String> {
223 let mut current = String::new();
249 let mut current = String::new();
224 let mut best: Option<String> = None;
250 let mut best: Option<String> = None;
225
251
@@ -238,51 +264,68 @@ pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
238 best
264 best
239}
265}
240
266
267+fn handle_manager_line(line: &str, last_status: &mut Option<String>) -> Result<()> {
268+ if let Some(status) = line.strip_prefix(STATUS_PREFIX) {
269+ let status = status.trim();
270+ if let Some(message) = status.strip_prefix(STATUS_VM_ERROR_PREFIX) {
271+ let message = message.trim();
272+ if message.is_empty() {
273+ bail!("vm manager reported startup failure");
274+ }
275+ return Err(InstanceError::VMError(message.to_string()).into());
276+ }
277+ if !status.is_empty() && last_status.as_deref() != Some(status) {
278+ tracing::info!("[background]: {}", status);
279+ *last_status = Some(status.to_string());
280+ }
281+ }
282+ Ok(())
283+}
284+
241fn wait_for_vm_ipv4(
285fn wait_for_vm_ipv4(
242− instance_dir: &Path,
286+ project_dir: &Path,
243 timeout: Duration,
287 timeout: Duration,
244−) -> Result<(), Box<dyn std::error::Error>> {
288+ manager_conn: &UnixStream,
289+) -> Result<()> {
245 let start = Instant::now();
290 let start = Instant::now();
246 let mut next_log_at = start + Duration::from_secs(10);
291 let mut next_log_at = start + Duration::from_secs(10);
247− let mut next_status_check = start;
292+ let mut stream = manager_conn.try_clone()?;
293+ let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
294+ let mut read_buf = [0u8; 1024];
295+ let mut pending = String::new();
248 tracing::info!("waiting for vm ipv4");
296 tracing::info!("waiting for vm ipv4");
249− let status_path = instance_dir.join(STATUS_FILE_NAME);
250 let mut last_status: Option<String> = None;
297 let mut last_status: Option<String> = None;
251− let mut status_missing = true;
252 let mut once_hint = false;
298 let mut once_hint = false;
253 loop {
299 loop {
300+ match stream.read(&mut read_buf) {
301+ Ok(0) => {
302+ bail!("vm manager disconnected before VM became ready");
303+ }
304+ Ok(n) => {
305+ pending.push_str(&String::from_utf8_lossy(&read_buf[..n]));
306+ while let Some(pos) = pending.find('\n') {
307+ let line = pending[..pos].trim().to_string();
308+ pending.drain(..=pos);
309+ handle_manager_line(&line, &mut last_status)?;
310+ }
311+ }
312+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
313+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
314+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
315+ Err(err) => {
316+ tracing::warn!(error = %err, "failed to read vm manager status stream");
317+ }
318+ }
319+
254− let config = load_or_create_instance_config(instance_dir)?;
320+ let config = load_or_create_instance_config(project_dir)?;
255 if config.vm_ipv4.is_some() {
321 if config.vm_ipv4.is_some() {
256− let _ = fs::remove_file(&status_path);
257 return Ok(());
322 return Ok(());
258 }
323 }
259 if start.elapsed() > timeout {
324 if start.elapsed() > timeout {
260− let _ = fs::remove_file(&status_path);
261− return Err("Timed out waiting for VM IPv4".into());
325+ bail!("timed out waiting for VM IPv4");
262 }
326 }
327+
263 let now = Instant::now();
328 let now = Instant::now();
264− if now >= next_status_check {
265− match fs::read_to_string(&status_path) {
266− Ok(status) => {
267− status_missing = false;
268− let status = status.trim().to_string();
269− if status.starts_with("error:") {
270− let _ = fs::remove_file(&status_path);
271− let message = status.trim_start_matches("error:").trim().to_string();
272− return Err(message.into());
273− }
274− if !status.is_empty() && last_status.as_deref() != Some(status.as_str()) {
275− tracing::info!("[background]: {}", status);
276− last_status = Some(status);
277− next_log_at = now + Duration::from_secs(20);
278− }
279− }
280− Err(_) => {
281− status_missing = true;
282− }
283− }
284− next_status_check = now + Duration::from_millis(500);
285− }
286 if now >= next_log_at {
329 if now >= next_log_at {
287 let waited = start.elapsed();
330 let waited = start.elapsed();
288 if waited.as_secs() > 15 && !once_hint {
331 if waited.as_secs() > 15 && !once_hint {
@@ -291,12 +334,9 @@ fn wait_for_vm_ipv4(
291 );
334 );
292 once_hint = true;
335 once_hint = true;
293 }
336 }
294− if status_missing {
295− tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
337+ tracing::info!("still waiting for vm ipv4, {}s elapsed", waited.as_secs(),);
296− }
297 next_log_at += Duration::from_secs(20);
338 next_log_at += Duration::from_secs(20);
298 }
339 }
299− thread::sleep(Duration::from_millis(200));
300 }
340 }
301}
341}
302
342
@@ -304,9 +344,14 @@ fn run_ssh_session(
304 ssh_key: PathBuf,
344 ssh_key: PathBuf,
305 ssh_user: String,
345 ssh_user: String,
306 ip: String,
346 ip: String,
307−) -> Result<(), Box<dyn std::error::Error>> {
347+ manager_conn: UnixStream,
348+ project_root: PathBuf,
349+) -> Result<()> {
308 let mut attempts = 0usize;
350 let mut attempts = 0usize;
309 loop {
351 loop {
352+ if matches!(vm_liveness(&project_root)?, VmLiveness::NotRunningOrMissing) {
353+ return Err(InstanceError::UnexpectedDisconnection.into());
354+ }
310 attempts += 1;
355 attempts += 1;
311 if !ssh_port_open(&ip) {
356 if !ssh_port_open(&ip) {
312 tracing::debug!(attempts, "ssh port doesn't open yet");
357 tracing::debug!(attempts, "ssh port doesn't open yet");
@@ -318,9 +363,7 @@ fn run_ssh_session(
318 SSH_CONNECT_RETRIES
363 SSH_CONNECT_RETRIES
319 );
364 );
320 if attempts >= SSH_CONNECT_RETRIES {
365 if attempts >= SSH_CONNECT_RETRIES {
321− return Err(
322− format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
366+ bail!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts");
323− );
324 }
367 }
325 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
368 thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
326 continue;
369 continue;
@@ -334,10 +377,10 @@ fn run_ssh_session(
334 attempts,
377 attempts,
335 SSH_CONNECT_RETRIES
378 SSH_CONNECT_RETRIES
336 );
379 );
337− let status = Command::new("ssh")
380+ let child = Command::new("ssh")
338 .args([
381 .args([
339 "-i",
382 "-i",
340− ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
383+ ssh_key.to_str().with_context(|| "invalid path")?,
341 "-o",
384 "-o",
342 "IdentitiesOnly=yes",
385 "IdentitiesOnly=yes",
343 "-o",
386 "-o",
@@ -362,27 +405,67 @@ fn run_ssh_session(
362 .stdin(Stdio::inherit())
405 .stdin(Stdio::inherit())
363 .stdout(Stdio::inherit())
406 .stdout(Stdio::inherit())
364 .stderr(Stdio::inherit())
407 .stderr(Stdio::inherit())
408+ .spawn();
409+
410+ match child {
411+ Ok(mut child) => {
412+ let done = Arc::new(AtomicBool::new(false));
413+ let done_for_monitor = done.clone();
414+ let (disconnect_tx, disconnect_rx) = mpsc::channel::<()>();
415+ let mut manager_stream = manager_conn.try_clone()?;
416+ let _ = manager_stream.set_read_timeout(Some(Duration::from_millis(250)));
417+ thread::spawn(move || {
418+ let mut buf = [0u8; 1];
419+ while !done_for_monitor.load(Ordering::Relaxed) {
420+ match manager_stream.read(&mut buf) {
421+ Ok(0) => {
422+ let _ = disconnect_tx.send(());
423+ return;
424+ }
425+ Ok(_) => {}
426+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
427+ Err(err) if err.kind() == io::ErrorKind::TimedOut => {}
428+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
429+ Err(_) => {
430+ let _ = disconnect_tx.send(());
431+ return;
432+ }
433+ }
434+ }
435+ });
436+
437+ let status = loop {
438+ if disconnect_rx.try_recv().is_ok() {
439+ done.store(true, Ordering::Relaxed);
440+ terminate_ssh_child(&mut child);
441+ restore_terminal_after_disconnect();
442+ return Err(InstanceError::UnexpectedDisconnection.into());
443+ }
444+ if let Some(status) = child.try_wait()? {
445+ done.store(true, Ordering::Relaxed);
365− .status();
446+ break status;
447+ }
448+ thread::sleep(Duration::from_millis(100));
449+ };
366
450
367− match status {
368− Ok(status) if status.success() => {
451+ if status.success() {
369− tracing::info!(status = %status, "ssh exited");
452+ tracing::info!(status = %status, "ssh exited");
370− break;
453+ break;
371− }
454+ }
372− Ok(status) if status.code() == Some(255) => {
455+ if status.code() == Some(255) {
373− tracing::warn!(status = %status, "ssh connection failed");
456+ tracing::warn!(status = %status, "ssh connection failed");
374− if attempts >= SSH_CONNECT_RETRIES {
457+ if attempts >= SSH_CONNECT_RETRIES {
375− return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
458+ bail!("ssh failed after {SSH_CONNECT_RETRIES} attempts");
459+ }
460+ thread::sleep(Duration::from_millis(500));
461+ continue;
376 }
462 }
377− thread::sleep(Duration::from_millis(500));
378− }
379− Ok(status) => {
380 tracing::info!(status = %status, "ssh exited");
463 tracing::info!(status = %status, "ssh exited");
381 break;
464 break;
382 }
465 }
383 Err(err) => {
466 Err(err) => {
384 tracing::error!(error = %err, "failed to start ssh");
467 tracing::error!(error = %err, "failed to start ssh");
385− return Err(format!("failed to start ssh: {err}").into());
468+ bail!("failed to start ssh: {err}");
386 }
469 }
387 }
470 }
388 }
471 }
@@ -390,6 +473,30 @@ fn run_ssh_session(
390 Ok(())
473 Ok(())
391}
474}
392
475
476+fn terminate_ssh_child(child: &mut Child) {
477+ let pid = child.id() as i32;
478+ unsafe {
479+ libc::kill(pid, libc::SIGTERM);
480+ }
481+ let deadline = Instant::now() + Duration::from_millis(700);
482+ while Instant::now() < deadline {
483+ match child.try_wait() {
484+ Ok(Some(_)) => return,
485+ Ok(None) => thread::sleep(Duration::from_millis(50)),
486+ Err(_) => break,
487+ }
488+ }
489+ let _ = child.kill();
490+ let _ = child.wait();
491+}
492+
493+fn restore_terminal_after_disconnect() {
494+ if io::stdin().is_terminal() {
495+ let _ = Command::new("stty").arg("sane").status();
496+ }
497+ eprintln!();
498+}
499+
393#[cfg_attr(feature = "mock-vm", allow(dead_code))]
500#[cfg_attr(feature = "mock-vm", allow(dead_code))]
394fn is_ipv4_candidate(candidate: &str) -> bool {
501fn is_ipv4_candidate(candidate: &str) -> bool {
395 let parts: Vec<&str> = candidate.split('.').collect();
502 let parts: Vec<&str> = candidate.split('.').collect();
@@ -412,10 +519,10 @@ fn ssh_port_open(ip: &str) -> bool {
412 Ok(addr) => addr,
519 Ok(addr) => addr,
413 Err(_) => return false,
520 Err(_) => return false,
414 };
521 };
415− TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
522+ TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok()
416}
523}
417
524
418−pub(crate) fn build_ssh_login_actions(
525+pub fn build_ssh_login_actions(
419 config: &Arc<Mutex<InstanceConfig>>,
526 config: &Arc<Mutex<InstanceConfig>>,
420 project_name: &str,
527 project_name: &str,
421 project_guest_dir: &str,
528 project_guest_dir: &str,
@@ -430,7 +537,7 @@ pub(crate) fn build_ssh_login_actions(
430
537
431 let key_path = format!("{guest_dir}/{key_name}.pub");
538 let key_path = format!("{guest_dir}/{key_name}.pub");
432
539
433− let setup_script = SSH_SETUP_SCRIPT
540+ let ssh_script = SSH_SETUP_SCRIPT
434 .replace("__SSH_USER__", &ssh_user)
541 .replace("__SSH_USER__", &ssh_user)
435 .replace("__SUDO_PASSWORD__", &sudo_password)
542 .replace("__SUDO_PASSWORD__", &sudo_password)
436 .replace("__PROJECT_NAME__", project_name)
543 .replace("__PROJECT_NAME__", project_name)
@@ -438,8 +545,36 @@ pub(crate) fn build_ssh_login_actions(
438 .replace("__KEY_PATH__", &key_path)
545 .replace("__KEY_PATH__", &key_path)
439 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
546 .replace("__VIBEBOX_SHELL_SCRIPT__", &commands::render_shell_script())
440 .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
547 .replace("__VIBEBOX_HOME_LINKS__", home_links_script);
441− let setup = vm::script_command_from_content("ssh_setup", &setup_script)
548+ let setup = vm::script_command_from_content("ssh.sh", &ssh_script)
442 .expect("ssh setup script contained invalid marker");
549 .expect("ssh setup script contained invalid marker");
443
550
444 vec![LoginAction::Send(setup)]
551 vec![LoginAction::Send(setup)]
445}
552}
553+
554+#[cfg(test)]
555+mod tests {
556+ use super::*;
557+
558+ #[test]
559+ fn handle_manager_line_updates_status() {
560+ let mut last_status = None;
561+ handle_manager_line("status: preparing VM image...", &mut last_status)
562+ .expect("status should be accepted");
563+ assert_eq!(last_status.as_deref(), Some("preparing VM image..."));
564+ }
565+
566+ #[test]
567+ fn handle_manager_line_ignores_non_status_lines() {
568+ let mut last_status = None;
569+ handle_manager_line("pid=123", &mut last_status).expect("non-status lines are ignored");
570+ assert!(last_status.is_none());
571+ }
572+
573+ #[test]
574+ fn handle_manager_line_surfaces_error_status() {
575+ let mut last_status = None;
576+ let err = handle_manager_line("status: error: vm failed to boot", &mut last_status)
577+ .expect_err("error status should fail");
578+ assert_eq!(err.to_string(), "vm failed to boot");
579+ }
580+}
src/lib.rs+1
@@ -8,3 +8,4 @@ pub mod vm_manager;
8
8
9pub use session_manager::{SessionError, SessionManager, SessionRecord};
9pub use session_manager::{SessionError, SessionManager, SessionRecord};
10pub mod config;
10pub mod config;
11+pub mod utils;
src/session_manager.rs+56−93
@@ -1,19 +1,18 @@
1+use anyhow::Result;
1use std::{
2use std::{
2 env, fs,
3 env, fs,
3 io::{self, Write},
4 io::{self, Write},
4− os::unix::fs::FileTypeExt,
5 path::{Path, PathBuf},
5 path::{Path, PathBuf},
6};
6};
7
7
8+use crate::config::config_path;
9+use crate::instance::{VmLiveness, read_instance_config, vm_liveness};
8use serde::{Deserialize, Serialize};
10use serde::{Deserialize, Serialize};
9
11
10−use crate::config::CONFIG_FILENAME;
11−
12pub const INSTANCE_DIR_NAME: &str = ".vibebox";
12pub const INSTANCE_DIR_NAME: &str = ".vibebox";
13pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
13pub const GLOBAL_CACHE_DIR_NAME: &str = "vibebox";
14pub const GLOBAL_DIR_NAME: &str = ".vibebox";
14pub const GLOBAL_DIR_NAME: &str = ".vibebox";
15−pub const INSTANCE_FILENAME: &str = "instance.toml";
16−pub const SESSION_TOML_SUFFIX: &str = ".toml";
15+const SESSION_TOML_SUFFIX: &str = ".toml";
17pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
16pub const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
18pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
17pub const VM_MANAGER_PID_NAME: &str = "vm.pid";
19const SESSIONS_DIR_NAME: &str = "sessions";
18const SESSIONS_DIR_NAME: &str = "sessions";
@@ -32,14 +31,6 @@ struct SessionEntry {
32 pub id: String,
31 pub id: String,
33}
32}
34
33
35−#[derive(Debug, Default, Deserialize)]
36−struct InstanceMetadata {
37− #[serde(default)]
38− id: Option<String>,
39− #[serde(default)]
40− last_active: Option<String>,
41−}
42−
43#[derive(Debug)]
34#[derive(Debug)]
44pub struct SessionManager {
35pub struct SessionManager {
45 sessions_dir: PathBuf,
36 sessions_dir: PathBuf,
@@ -61,7 +52,7 @@ pub enum SessionError {
61 #[error("Session directory does not exist: {0}")]
52 #[error("Session directory does not exist: {0}")]
62 MissingDirectory(PathBuf),
53 MissingDirectory(PathBuf),
63 #[error(transparent)]
54 #[error(transparent)]
64− Io(#[from] std::io::Error),
55+ Io(#[from] io::Error),
65 #[error(transparent)]
56 #[error(transparent)]
66 TomlDe(#[from] toml::de::Error),
57 TomlDe(#[from] toml::de::Error),
67 #[error(transparent)]
58 #[error(transparent)]
@@ -94,8 +85,8 @@ impl SessionManager {
94 let mut added = false;
85 let mut added = false;
95
86
96 if has_config {
87 if has_config {
97− let meta = read_instance_metadata(&directory)?;
88+ let id = read_instance_config(&directory).map_or(None, |config| Some(config.id));
98− if let Some(id) = meta.id {
89+ if let Some(id) = id {
99 let record = SessionEntry {
90 let record = SessionEntry {
100 directory: directory.clone(),
91 directory: directory.clone(),
101 id: id.clone(),
92 id: id.clone(),
@@ -110,7 +101,6 @@ impl SessionManager {
110 } else {
101 } else {
111 tracing::warn!(
102 tracing::warn!(
112 directory = %directory.display(),
103 directory = %directory.display(),
113− file = INSTANCE_FILENAME,
114 "missing session id in instance file"
104 "missing session id in instance file"
115 );
105 );
116 }
106 }
@@ -148,12 +138,13 @@ impl SessionManager {
148 }
138 }
149 let mut records = Vec::with_capacity(sessions.len());
139 let mut records = Vec::with_capacity(sessions.len());
150 for session in sessions {
140 for session in sessions {
151− let meta = read_instance_metadata(&session.directory)?;
141+ let last_active =
142+ read_instance_config(&session.directory).map_or(None, |option| option.last_active);
152 let active = is_session_active(&session.directory);
143 let active = is_session_active(&session.directory);
153 records.push(SessionRecord {
144 records.push(SessionRecord {
154 directory: session.directory,
145 directory: session.directory,
155 id: session.id,
146 id: session.id,
156− last_active: meta.last_active,
147+ last_active,
157 active,
148 active,
158 });
149 });
159 }
150 }
@@ -271,43 +262,22 @@ fn is_vibebox_dir(directory: &Path) -> bool {
271 if !directory.is_absolute() {
262 if !directory.is_absolute() {
272 return false;
263 return false;
273 }
264 }
274− directory.join(CONFIG_FILENAME).is_file()
265+ config_path(directory).is_file()
275}
266}
276
267
277fn is_session_active(directory: &Path) -> bool {
268fn is_session_active(directory: &Path) -> bool {
278 let instance_dir = directory.join(INSTANCE_DIR_NAME);
269 let instance_dir = directory.join(INSTANCE_DIR_NAME);
279 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
270 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
280− let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
271+ match vm_liveness(directory) {
281−
272+ Ok(liveness) => match liveness {
282− let pid = read_pid(&pid_path);
273+ VmLiveness::RunningWithSocket { .. } => true,
283− let is_alive = pid.map(pid_is_alive).unwrap_or(false);
274+ VmLiveness::RunningWithoutSocket { .. } => true,
284− if !is_alive {
275+ VmLiveness::NotRunningOrMissing => {
285− let _ = fs::remove_file(&pid_path);
276+ let _ = fs::remove_file(&pid_path);
286− return false;
277+ false
287− }
278+ }
288−
289− if let Ok(metadata) = fs::metadata(&socket_path) {
290− return metadata.file_type().is_socket();
291− }
292−
293− true
294−}
295−
296−fn read_pid(path: &Path) -> Option<u32> {
297− let content = fs::read_to_string(path).ok()?;
298− content.trim().parse::<u32>().ok()
299−}
300−
301−fn pid_is_alive(pid: u32) -> bool {
302− let pid = pid as libc::pid_t;
303− let result = unsafe { libc::kill(pid, 0) };
304− if result == 0 {
305− return true;
306− }
279+ },
307− match std::io::Error::last_os_error().raw_os_error() {
308− Some(code) if code == libc::EPERM => true,
309− Some(code) if code == libc::ESRCH => false,
310− _ => false,
280+ Err(_) => false,
311 }
281 }
312}
282}
313
283
@@ -315,31 +285,11 @@ fn read_session_file(path: &Path) -> Result<SessionEntry, SessionError> {
315 let raw = fs::read_to_string(path)?;
285 let raw = fs::read_to_string(path)?;
316 let record: SessionEntry = toml::from_str(&raw)?;
286 let record: SessionEntry = toml::from_str(&raw)?;
317 if record.id.trim().is_empty() {
287 if record.id.trim().is_empty() {
318− return Err(std::io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
288+ return Err(io::Error::new(io::ErrorKind::InvalidData, "session id missing").into());
319 }
289 }
320 Ok(record)
290 Ok(record)
321}
291}
322
292
323−fn read_instance_metadata(directory: &Path) -> Result<InstanceMetadata, SessionError> {
324− let instance_path = directory.join(INSTANCE_DIR_NAME).join(INSTANCE_FILENAME);
325− if !instance_path.exists() {
326− return Ok(InstanceMetadata::default());
327− }
328− let raw = fs::read_to_string(&instance_path)?;
329− let mut meta: InstanceMetadata = toml::from_str(&raw)?;
330− if let Some(id) = &meta.id
331− && id.trim().is_empty()
332− {
333− meta.id = None;
334− }
335− if let Some(last_active) = &meta.last_active
336− && last_active.trim().is_empty()
337− {
338− meta.last_active = None;
339− }
340− Ok(meta)
341−}
342−
343fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
293fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
344 let Some(parent) = path.parent() else {
294 let Some(parent) = path.parent() else {
345 return Err(io::Error::new(
295 return Err(io::Error::new(
@@ -362,6 +312,7 @@ fn atomic_write(path: &Path, content: &[u8]) -> io::Result<()> {
362#[cfg(test)]
312#[cfg(test)]
363mod tests {
313mod tests {
364 use super::*;
314 use super::*;
315+ use crate::instance::{InstanceConfig, write_instance_config};
365 use std::fs;
316 use std::fs;
366 use tempfile::TempDir;
317 use tempfile::TempDir;
367
318
@@ -375,11 +326,14 @@ mod tests {
375 dir
326 dir
376 }
327 }
377
328
378− fn write_instance(project_dir: &Path, id: &str, last_active: &str) {
329+ fn write_instance(project_dir: &Path, id: &str, last_active: &str) -> Result<()> {
379− let instance_dir = project_dir.join(INSTANCE_DIR_NAME);
380− fs::create_dir_all(&instance_dir).unwrap();
330+ fs::create_dir_all(project_dir)?;
381− let content = format!("id = \"{id}\"\nlast_active = \"{last_active}\"\n");
331+
382− fs::write(instance_dir.join(INSTANCE_FILENAME), content).unwrap();
332+ let mut config = InstanceConfig::default();
333+ config.id = id.to_string();
334+ config.last_active = Some(last_active.to_string());
335+
336+ write_instance_config(project_dir, &config)
383 }
337 }
384
338
385 #[test]
339 #[test]
@@ -387,11 +341,14 @@ mod tests {
387 let temp = TempDir::new().unwrap();
341 let temp = TempDir::new().unwrap();
388 let mgr = manager(&temp);
342 let mgr = manager(&temp);
389 let project_dir = create_project_dir(&temp);
343 let project_dir = create_project_dir(&temp);
390− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
344+ fs::write(config_path(project_dir.as_path()), "").unwrap();
345+ assert!(
391− write_instance(
346+ write_instance(
392− &project_dir,
347+ &project_dir,
393− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
348+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
394− "2026-02-07T05:00:00Z",
349+ "2026-02-07T05:00:00Z",
350+ )
351+ .is_ok()
395 );
352 );
396
353
397 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
354 let dirs = mgr.update_global_sessions(&project_dir).unwrap();
@@ -412,15 +369,18 @@ mod tests {
412 let temp = TempDir::new().unwrap();
369 let temp = TempDir::new().unwrap();
413 let mgr = manager(&temp);
370 let mgr = manager(&temp);
414 let project_dir = create_project_dir(&temp);
371 let project_dir = create_project_dir(&temp);
415− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
372+ fs::write(config_path(project_dir.as_path()), "").unwrap();
373+ assert!(
416− write_instance(
374+ write_instance(
417− &project_dir,
375+ &project_dir,
418− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
376+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
419− "2026-02-07T05:00:00Z",
377+ "2026-02-07T05:00:00Z",
378+ )
379+ .is_ok()
420 );
380 );
421 let _ = mgr.update_global_sessions(&project_dir).unwrap();
381 let _ = mgr.update_global_sessions(&project_dir).unwrap();
422
382
423− fs::remove_file(project_dir.join(CONFIG_FILENAME)).unwrap();
383+ fs::remove_file(config_path(project_dir.as_path())).unwrap();
424 let sessions = mgr.list_sessions().unwrap();
384 let sessions = mgr.list_sessions().unwrap();
425 assert!(sessions.is_empty());
385 assert!(sessions.is_empty());
426
386
@@ -451,11 +411,14 @@ mod tests {
451 let temp = TempDir::new().unwrap();
411 let temp = TempDir::new().unwrap();
452 let mgr = manager(&temp);
412 let mgr = manager(&temp);
453 let project_dir = create_project_dir(&temp);
413 let project_dir = create_project_dir(&temp);
454− fs::write(project_dir.join(CONFIG_FILENAME), "").unwrap();
414+ fs::write(config_path(project_dir.as_path()), "").unwrap();
415+ assert!(
455− write_instance(
416+ write_instance(
456− &project_dir,
417+ &project_dir,
457− "019bf290-cccc-7c23-ba1d-dce7e6d40693",
418+ "019bf290-cccc-7c23-ba1d-dce7e6d40693",
458− "2026-02-07T05:00:00Z",
419+ "2026-02-07T05:00:00Z",
420+ )
421+ .is_ok()
459 );
422 );
460 let _ = mgr.update_global_sessions(&project_dir).unwrap();
423 let _ = mgr.update_global_sessions(&project_dir).unwrap();
461
424
src/ssh.sh+13−10
@@ -114,8 +114,8 @@ mise_ok() { command -v mise >/dev/null 2>&1 || [ -x "$MISE_BIN" ]; }
114mise_install() {
114mise_install() {
115 if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
115 if [ ! -x "$MISE_BIN" ] && ! command -v mise >/dev/null 2>&1; then
116 if ! curl https://mise.run | HOME="$USER_HOME" sh; then
116 if ! curl https://mise.run | HOME="$USER_HOME" sh; then
117− mise_warn "mise install script failed (continuing)"
117+ mise_warn "mise install script failed"
118− return 0
118+ return 1
119 fi
119 fi
120 fi
120 fi
121 echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
121 echo 'eval "$(~/.local/bin/mise activate bash)"' >> "${USER_HOME}/.bashrc"
@@ -141,18 +141,21 @@ MISE
141 touch "${USER_HOME}/.config/mise/mise.lock"
141 touch "${USER_HOME}/.config/mise/mise.lock"
142 if [ -x "$MISE_BIN" ]; then
142 if [ -x "$MISE_BIN" ]; then
143 if ! HOME="$USER_HOME" "$MISE_BIN" install; then
143 if ! HOME="$USER_HOME" "$MISE_BIN" install; then
144− mise_warn "mise install failed (continuing)"
144+ mise_warn "mise install failed"
145− return 0
145+ return 1
146 fi
146 fi
147 else
147 else
148 if ! HOME="$USER_HOME" mise install; then
148 if ! HOME="$USER_HOME" mise install; then
149− mise_warn "mise install failed (continuing)"
149+ mise_warn "mise install failed"
150− return 0
150+ return 1
151 fi
151 fi
152 fi
152 fi
153}
153}
154
154
155−mise_install || true
155+if ! mise_install; then
156+ diag "mise installation failed"
157+ vibebox_fail "mise installation failed" 1
158+fi
156
159
157# 3) start ssh (don't swallow failures)
160# 3) start ssh (don't swallow failures)
158# If ssh is already active, don't force start/restart.
161# If ssh is already active, don't force start/restart.
@@ -160,7 +163,7 @@ if ! systemctl is-active --quiet ssh; then
160 if ! systemctl start ssh; then
163 if ! systemctl start ssh; then
161 diag "systemctl start ssh failed"
164 diag "systemctl start ssh failed"
162 dump_diag
165 dump_diag
163− exit 1
166+ vibebox_fail "failed to start ssh service" 1
164 fi
167 fi
165fi
168fi
166
169
@@ -198,7 +201,7 @@ done
198if [ -z "$dev" ] || [ -z "$ip" ]; then
201if [ -z "$dev" ] || [ -z "$ip" ]; then
199 diag "no stable IPv4 on default route interface"
202 diag "no stable IPv4 on default route interface"
200 dump_diag
203 dump_diag
201− exit 1
204+ vibebox_fail "no stable ipv4 route on default interface" 1
202fi
205fi
203
206
204# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
207# 5) strong verify: ssh must listen externally (0.0.0.0:22 or $ip:22 or [::]:22)
@@ -217,7 +220,7 @@ done
217if ! listens_ok; then
220if ! listens_ok; then
218 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
221 diag "sshd not listening on 0.0.0.0:22 / ${ip}:22"
219 dump_diag
222 dump_diag
220− exit 1
223+ vibebox_fail "sshd is not listening on the expected address" 1
221fi
224fi
222
225
223ip a
226ip a
src/tui.rs+31−31
@@ -1,10 +1,4 @@
1−use std::{
1+use bytesize::ByteSize;
2− io::{self, Write},
3− os::unix::io::OwnedFd,
4− path::PathBuf,
5− sync::{Arc, Mutex},
6−};
7−
8use color_eyre::Result;
2use color_eyre::Result;
9use crossterm::{
3use crossterm::{
10 cursor::{MoveTo, Show},
4 cursor::{MoveTo, Show},
@@ -22,6 +16,12 @@ use ratatui::{
22 text::{Line, Span, Text},
16 text::{Line, Span, Text},
23 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
17 widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Widget},
24};
18};
19+use std::{
20+ io::{self, Write},
21+ os::unix::io::OwnedFd,
22+ path::PathBuf,
23+ sync::{Arc, Mutex},
24+};
25
25
26use crate::vm;
26use crate::vm;
27
27
@@ -39,9 +39,9 @@ const INFO_LINE_COUNT: u16 = 5;
39
39
40#[derive(Debug, Clone)]
40#[derive(Debug, Clone)]
41pub struct VmInfo {
41pub struct VmInfo {
42− pub max_memory_mb: u64,
42+ pub max_memory: ByteSize,
43 pub cpu_cores: usize,
43 pub cpu_cores: usize,
44− pub max_disk_gb: f32,
44+ pub max_disk: ByteSize,
45 pub system_name: String,
45 pub system_name: String,
46 pub auto_shutdown_ms: u64,
46 pub auto_shutdown_ms: u64,
47}
47}
@@ -474,27 +474,27 @@ fn write_buffer_with_style(buffer: &Buffer, out: &mut impl Write) -> io::Result<
474 Ok(())
474 Ok(())
475}
475}
476
476
477−fn map_color(color: ratatui::style::Color) -> CrosstermColor {
477+fn map_color(color: Color) -> CrosstermColor {
478 match color {
478 match color {
479− ratatui::style::Color::Reset => CrosstermColor::Reset,
479+ Color::Reset => CrosstermColor::Reset,
480− ratatui::style::Color::Black => CrosstermColor::Black,
480+ Color::Black => CrosstermColor::Black,
481− ratatui::style::Color::Red => CrosstermColor::DarkRed,
481+ Color::Red => CrosstermColor::DarkRed,
482− ratatui::style::Color::Green => CrosstermColor::DarkGreen,
482+ Color::Green => CrosstermColor::DarkGreen,
483− ratatui::style::Color::Yellow => CrosstermColor::DarkYellow,
483+ Color::Yellow => CrosstermColor::DarkYellow,
484− ratatui::style::Color::Blue => CrosstermColor::DarkBlue,
484+ Color::Blue => CrosstermColor::DarkBlue,
485− ratatui::style::Color::Magenta => CrosstermColor::DarkMagenta,
485+ Color::Magenta => CrosstermColor::DarkMagenta,
486− ratatui::style::Color::Cyan => CrosstermColor::DarkCyan,
486+ Color::Cyan => CrosstermColor::DarkCyan,
487− ratatui::style::Color::Gray => CrosstermColor::Grey,
487+ Color::Gray => CrosstermColor::Grey,
488− ratatui::style::Color::DarkGray => CrosstermColor::DarkGrey,
488+ Color::DarkGray => CrosstermColor::DarkGrey,
489− ratatui::style::Color::LightRed => CrosstermColor::Red,
489+ Color::LightRed => CrosstermColor::Red,
490− ratatui::style::Color::LightGreen => CrosstermColor::Green,
490+ Color::LightGreen => CrosstermColor::Green,
491− ratatui::style::Color::LightYellow => CrosstermColor::Yellow,
491+ Color::LightYellow => CrosstermColor::Yellow,
492− ratatui::style::Color::LightBlue => CrosstermColor::Blue,
492+ Color::LightBlue => CrosstermColor::Blue,
493− ratatui::style::Color::LightMagenta => CrosstermColor::Magenta,
493+ Color::LightMagenta => CrosstermColor::Magenta,
494− ratatui::style::Color::LightCyan => CrosstermColor::Cyan,
494+ Color::LightCyan => CrosstermColor::Cyan,
495− ratatui::style::Color::White => CrosstermColor::White,
495+ Color::White => CrosstermColor::White,
496− ratatui::style::Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
496+ Color::Rgb(r, g, b) => CrosstermColor::Rgb { r, g, b },
497− ratatui::style::Color::Indexed(i) => CrosstermColor::AnsiValue(i),
497+ Color::Indexed(i) => CrosstermColor::AnsiValue(i),
498 }
498 }
499}
499}
500
500
@@ -574,8 +574,8 @@ fn render_header(buffer: &mut Buffer, area: Rect, app: &AppState) {
574 Span::raw("CPU / Memory / Disk: "),
574 Span::raw("CPU / Memory / Disk: "),
575 Span::styled(
575 Span::styled(
576 format!(
576 format!(
577− "{} cores / {} MB / {} GB",
577+ "{} cores / {} / {}",
578− app.vm_info.cpu_cores, app.vm_info.max_memory_mb, app.vm_info.max_disk_gb
578+ app.vm_info.cpu_cores, app.vm_info.max_memory, app.vm_info.max_disk
579 ),
579 ),
580 Style::default().fg(Color::Green),
580 Style::default().fg(Color::Green),
581 ),
581 ),
src/utils.rsnew file+18
@@ -0,0 +1,18 @@
1+use std::{
2+ env,
3+ path::{Path, PathBuf},
4+};
5+
6+pub fn relative_to_home(directory: &Path) -> String {
7+ let Ok(home) = env::var("HOME") else {
8+ return directory.display().to_string();
9+ };
10+ let home_path = PathBuf::from(home);
11+ if let Ok(stripped) = directory.strip_prefix(&home_path) {
12+ if stripped.components().next().is_none() {
13+ return "~".to_string();
14+ }
15+ return format!("~/{}", stripped.display());
16+ }
17+ directory.display().to_string()
18+}
src/vm.rs+126−159
@@ -1,5 +1,5 @@
1−use crate::instance::STATUS_FILE_NAME;
2use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
1use crate::session_manager::{GLOBAL_CACHE_DIR_NAME, INSTANCE_DIR_NAME};
2+use anyhow::{Context, Error, Result, bail};
3use std::{
3use std::{
4 env, fs,
4 env, fs,
5 io::{self, Write},
5 io::{self, Write},
@@ -23,6 +23,7 @@ use std::{
23};
23};
24
24
25use block2::RcBlock;
25use block2::RcBlock;
26+use bytesize::ByteSize;
26use dispatch2::DispatchQueue;
27use dispatch2::DispatchQueue;
27use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
28use objc2_foundation::*;
29use objc2_foundation::*;
@@ -34,49 +35,19 @@ const DEBIAN_COMPRESSED_SIZE_BYTES: u64 = 280901576;
34const SHARED_DIRECTORIES_TAG: &str = "shared";
35const SHARED_DIRECTORIES_TAG: &str = "shared";
35pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
36pub const PROJECT_GUEST_BASE: &str = "/usr/local/vibebox-mounts";
36
37
37−const BYTES_PER_MB: u64 = 1024 * 1024;
38−const DEFAULT_CPU_COUNT: usize = 2;
39−const DEFAULT_RAM_MB: u64 = 2048;
40−const DEFAULT_RAM_BYTES: u64 = DEFAULT_RAM_MB * BYTES_PER_MB;
41const START_TIMEOUT: Duration = Duration::from_secs(60);
38const START_TIMEOUT: Duration = Duration::from_secs(60);
42const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
39const LOGIN_EXPECT_TIMEOUT: Duration = Duration::from_secs(120);
43const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
40const PROVISION_EXPECT_TIMEOUT: Duration = Duration::from_secs(900);
44
41
45−struct StatusFile {
42+const ERROR_REPORT_SCRIPT: &str = include_str!("error_report.sh");
46− path: PathBuf,
47− cleared: AtomicBool,
48−}
49−
50−impl StatusFile {
51− fn new(path: PathBuf) -> Self {
52− Self {
53− path,
54− cleared: AtomicBool::new(false),
55− }
56− }
57−
58− fn update(&self, message: &str) {
59− let _ = fs::write(&self.path, message);
60− }
61−}
62−
63−impl Drop for StatusFile {
64− fn drop(&mut self) {
65− if !self.cleared.load(Ordering::SeqCst) {
66− let _ = fs::remove_file(&self.path);
67− self.cleared.store(true, Ordering::SeqCst);
68− }
69− }
70−}
71const PROVISION_SCRIPT: &str = include_str!("provision.sh");
43const PROVISION_SCRIPT: &str = include_str!("provision.sh");
72−const PROVISION_SCRIPT_NAME: &str = "provision.sh";
73const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
44const RESIZE_DISK_SCRIPT: &str = include_str!("resize_disk.sh");
74const DEFAULT_RAW_NAME: &str = "default.raw";
45const DEFAULT_RAW_NAME: &str = "default.raw";
75const INSTANCE_RAW_NAME: &str = "instance.raw";
46const INSTANCE_RAW_NAME: &str = "instance.raw";
76const BASE_DISK_RAW_NAME: &str = "disk.raw";
47const BASE_DISK_RAW_NAME: &str = "disk.raw";
77
48
78#[derive(Clone)]
49#[derive(Clone)]
79−pub(crate) enum LoginAction {
50+pub enum LoginAction {
80 Expect {
51 Expect {
81 text: String,
52 text: String,
82 timeout: Duration,
53 timeout: Duration,
@@ -88,23 +59,20 @@ pub(crate) enum LoginAction {
88 },
59 },
89 Send(String),
60 Send(String),
90}
61}
62+use crate::config::BoxConfig;
91use LoginAction::*;
63use LoginAction::*;
92
64
93#[derive(Clone)]
65#[derive(Clone)]
94−pub(crate) struct DirectoryShare {
66+pub struct DirectoryShare {
95 host: PathBuf,
67 host: PathBuf,
96 guest: PathBuf,
68 guest: PathBuf,
97 read_only: bool,
69 read_only: bool,
98}
70}
99
71
100impl DirectoryShare {
72impl DirectoryShare {
101− pub(crate) fn new(
102− host: PathBuf,
103− mut guest: PathBuf,
73+ pub fn new(host: PathBuf, mut guest: PathBuf, read_only: bool) -> Result<Self> {
104− read_only: bool,
105− ) -> Result<Self, Box<dyn std::error::Error>> {
106 if !host.exists() {
74 if !host.exists() {
107− return Err(format!("Host path does not exist: {}", host.display()).into());
75+ bail!(format!("host path does not exist: {}", host.display()));
108 }
76 }
109 if !guest.is_absolute() {
77 if !guest.is_absolute() {
110 guest = PathBuf::from("/root").join(guest);
78 guest = PathBuf::from("/root").join(guest);
@@ -116,10 +84,10 @@ impl DirectoryShare {
116 })
84 })
117 }
85 }
118
86
119− 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> {
120 let parts: Vec<&str> = spec.split(':').collect();
88 let parts: Vec<&str> = spec.split(':').collect();
121 if parts.len() < 2 || parts.len() > 3 {
89 if parts.len() < 2 || parts.len() > 3 {
122− return Err(format!("Invalid mount spec: {spec}").into());
90+ bail!(format!("invalid mount spec: {spec}"));
123 }
91 }
124 let host = expand_tilde_path(parts[0]);
92 let host = expand_tilde_path(parts[0]);
125 let guest = PathBuf::from(parts[1]);
93 let guest = PathBuf::from(parts[1]);
@@ -128,11 +96,10 @@ impl DirectoryShare {
128 "read-only" => true,
96 "read-only" => true,
129 "read-write" => false,
97 "read-write" => false,
130 _ => {
98 _ => {
131− return Err(format!(
99+ bail!(format!(
132 "Invalid mount mode '{}'; expected read-only or read-write",
100 "Invalid mount mode '{}'; expected read-only or read-write",
133 parts[2]
101 parts[2]
134− )
102+ ));
135− .into());
136 }
103 }
137 }
104 }
138 } else {
105 } else {
@@ -176,19 +143,28 @@ pub struct VmArg {
176 pub mounts: Vec<String>,
143 pub mounts: Vec<String>,
177}
144}
178
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+
179−pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
154+pub fn run_with_args<F>(args: VmArg, io_handler: F) -> Result<()>
180where
155where
181 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
156 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
182{
157{
183− 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)
184}
159}
185
160
186−pub(crate) fn run_with_args_and_extras<F>(
161+pub fn run_with_args_and_extras<F>(
187 args: VmArg,
162 args: VmArg,
188 io_handler: F,
163 io_handler: F,
189 extra_login_actions: Vec<LoginAction>,
164 extra_login_actions: Vec<LoginAction>,
190 extra_directory_shares: Vec<DirectoryShare>,
165 extra_directory_shares: Vec<DirectoryShare>,
191−) -> Result<(), Box<dyn std::error::Error>>
166+ status: Option<&StatusEmitter<'_>>,
167+) -> Result<()>
192where
168where
193 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
169 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
194{
170{
@@ -197,7 +173,7 @@ where
197 let project_root = env::current_dir()?;
173 let project_root = env::current_dir()?;
198 let project_name = project_root
174 let project_name = project_root
199 .file_name()
175 .file_name()
200− .ok_or("Project directory has no name")?
176+ .with_context(|| "Project directory has no name")?
201 .to_string_lossy()
177 .to_string_lossy()
202 .into_owned();
178 .into_owned();
203
179
@@ -210,8 +186,8 @@ where
210
186
211 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
187 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
212 fs::create_dir_all(&instance_dir)?;
188 fs::create_dir_all(&instance_dir)?;
213− let status_file = StatusFile::new(instance_dir.join(STATUS_FILE_NAME));
189+ emit_status(status, "preparing VM image...");
214− status_file.update("preparing VM image...");
190+ tracing::info!("preparing VM image...");
215 let provision_log = instance_dir.join("provision.log");
191 let provision_log = instance_dir.join("provision.log");
216
192
217 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
193 let basename_compressed = DEBIAN_COMPRESSED_DISK_URL.rsplit('/').next().unwrap();
@@ -236,14 +212,14 @@ where
236 &base_compressed,
212 &base_compressed,
237 &default_raw,
213 &default_raw,
238 std::slice::from_ref(&mise_directory_share),
214 std::slice::from_ref(&mise_directory_share),
239− Some(&status_file),
240 Some(&provision_log),
215 Some(&provision_log),
216+ status,
241 )?;
217 )?;
242 let _ = ensure_instance_disk(
218 let _ = ensure_instance_disk(
243 &instance_raw,
219 &instance_raw,
244 &default_raw,
220 &default_raw,
245− args.disk_bytes,
221+ ByteSize(args.disk_bytes),
246− Some(&status_file),
222+ status,
247 )?;
223 )?;
248 let base_size = fs::metadata(&default_raw)?.len();
224 let base_size = fs::metadata(&default_raw)?.len();
249 let instance_size = fs::metadata(&instance_raw)?.len();
225 let instance_size = fs::metadata(&instance_raw)?.len();
@@ -273,7 +249,7 @@ where
273 }
249 }
274
250
275 if needs_resize {
251 if needs_resize {
276− let resize_cmd = script_command_from_content("resize_disk", RESIZE_DISK_SCRIPT)?;
252+ let resize_cmd = script_command_from_content("resize_disk.sh", RESIZE_DISK_SCRIPT)?;
277 login_actions.push(Send(resize_cmd));
253 login_actions.push(Send(resize_cmd));
278 }
254 }
279
255
@@ -289,25 +265,29 @@ where
289 &directory_shares[..],
265 &directory_shares[..],
290 args.cpu_count,
266 args.cpu_count,
291 args.ram_bytes,
267 args.ram_bytes,
292− Some(&status_file),
268+ status,
293 io_handler,
269 io_handler,
294 )
270 )
295}
271}
296
272
297−pub(crate) fn script_command_from_content(
273+pub fn script_command_from_content(label: &str, script: &str) -> Result<String> {
298− label: &str,
299− script: &str,
300−) -> Result<String, Box<dyn std::error::Error>> {
301 let marker = "VIBE_SCRIPT_EOF";
274 let marker = "VIBE_SCRIPT_EOF";
302 let guest_dir = "/tmp/vibe-scripts";
275 let guest_dir = "/tmp/vibe-scripts";
303 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,
279+ _ => script,
280+ };
281+ let wrapped_script = ERROR_REPORT_SCRIPT
282+ .replace("__LABEL__", label)
283+ .replace("__SCRIPT_BODY__", script_body);
304 let command = format!(
284 let command = format!(
305− "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}"
306 );
286 );
307 if script.contains(marker) {
287 if script.contains(marker) {
308− return Err(
288+ bail!(format!(
309− format!("Script '{label}' contains marker '{marker}', cannot safely upload").into(),
289+ "Script '{label}' contains marker '{marker}', cannot safely upload"
310− );
290+ ));
311 }
291 }
312 Ok(command)
292 Ok(command)
313}
293}
@@ -504,18 +484,16 @@ impl IoControl {
504fn ensure_base_image(
484fn ensure_base_image(
505 base_raw: &Path,
485 base_raw: &Path,
506 base_compressed: &Path,
486 base_compressed: &Path,
507− status: Option<&StatusFile>,
487+ status: Option<&StatusEmitter<'_>>,
508−) -> Result<(), Box<dyn std::error::Error>> {
488+) -> Result<()> {
509 if base_raw.exists() {
489 if base_raw.exists() {
510 return Ok(());
490 return Ok(());
511 }
491 }
512
492
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
515 {
495 {
516− if let Some(status) = status {
517− status.update("downloading base image...");
496+ emit_status(status, "downloading base image...");
518− }
519 tracing::info!("downloading base image");
497 tracing::info!("downloading base image");
520 let status = Command::new("curl")
498 let status = Command::new("curl")
521 .args([
499 .args([
@@ -530,15 +508,14 @@ fn ensure_base_image(
530 ])
508 ])
531 .status()?;
509 .status()?;
532 if !status.success() {
510 if !status.success() {
533− return Err("Failed to download base image".into());
511+ bail!("failed to download base image");
534 }
512 }
535 }
513 }
536
514
537 // Check SHA
515 // Check SHA
538 {
516 {
539− if let Some(status) = status {
517+ emit_status(status, "verifying base image...");
540− status.update("verifying base image...");
518+ tracing::info!("verifying base image...");
541− }
542 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
519 let input = format!("{} {}\n", DEBIAN_COMPRESSED_SHA, base_compressed.display());
543
520
544 let mut child = Command::new("/usr/bin/shasum")
521 let mut child = Command::new("/usr/bin/shasum")
@@ -556,25 +533,25 @@ fn ensure_base_image(
556
533
557 let status = child.wait().expect("failed to wait on child");
534 let status = child.wait().expect("failed to wait on child");
558 if !status.success() {
535 if !status.success() {
536+ bail!(format!(
559− return Err(format!("SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}").into());
537+ "SHA validation failed for {DEBIAN_COMPRESSED_DISK_URL}"
538+ ));
560 }
539 }
561 }
540 }
562
541
563− if let Some(status) = status {
564− status.update("decompressing base image...");
542+ emit_status(status, "decompressing base image...");
565− }
566− tracing::info!("decompressing base image");
543+ tracing::info!("decompressing base image...");
567 let status = Command::new("tar")
544 let status = Command::new("tar")
568 .args([
545 .args([
569 "-xOf",
546 "-xOf",
570 &base_compressed.to_string_lossy(),
547 &base_compressed.to_string_lossy(),
571 BASE_DISK_RAW_NAME,
548 BASE_DISK_RAW_NAME,
572 ])
549 ])
573− .stdout(std::fs::File::create(base_raw)?)
550+ .stdout(fs::File::create(base_raw)?)
574 .status()?;
551 .status()?;
575
552
576 if !status.success() {
553 if !status.success() {
577− return Err("Failed to decompress base image".into());
554+ bail!("Failed to decompress base image");
578 }
555 }
579
556
580 Ok(())
557 Ok(())
@@ -585,22 +562,20 @@ fn ensure_default_image(
585 base_compressed: &Path,
562 base_compressed: &Path,
586 default_raw: &Path,
563 default_raw: &Path,
587 directory_shares: &[DirectoryShare],
564 directory_shares: &[DirectoryShare],
588− status: Option<&StatusFile>,
589 provision_log: Option<&Path>,
565 provision_log: Option<&Path>,
590−) -> Result<(), Box<dyn std::error::Error>> {
566+ status: Option<&StatusEmitter<'_>>,
567+) -> Result<()> {
591 if default_raw.exists() {
568 if default_raw.exists() {
592 return Ok(());
569 return Ok(());
593 }
570 }
594
571
595 ensure_base_image(base_raw, base_compressed, status)?;
572 ensure_base_image(base_raw, base_compressed, status)?;
596
573
597− if let Some(status) = status {
598− status.update("configuring base image...");
574+ emit_status(status, "configuring base image...");
599− }
600− tracing::info!("configuring base image");
575+ tracing::info!("configuring base image...");
601 fs::copy(base_raw, default_raw)?;
576 fs::copy(base_raw, default_raw)?;
602
577
603− let provision_command = script_command_from_content(PROVISION_SCRIPT_NAME, PROVISION_SCRIPT)?;
578+ let provision_command = script_command_from_content("provision.sh", PROVISION_SCRIPT)?;
604 let provision_actions = [
579 let provision_actions = [
605 Send(provision_command),
580 Send(provision_command),
606 ExpectEither {
581 ExpectEither {
@@ -615,9 +590,9 @@ fn ensure_default_image(
615 default_raw,
590 default_raw,
616 &provision_actions,
591 &provision_actions,
617 directory_shares,
592 directory_shares,
618− DEFAULT_CPU_COUNT,
593+ BoxConfig::default().cpu_count,
619− DEFAULT_RAM_BYTES,
594+ BoxConfig::default().ram_size.as_u64(),
620− None,
595+ status,
621 move |output_monitor, vm_output_fd, vm_input_fd| {
596 move |output_monitor, vm_output_fd, vm_input_fd| {
622 spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
597 spawn_vm_io_with_log(output_monitor, vm_output_fd, vm_input_fd, log_path)
623 },
598 },
@@ -627,9 +602,9 @@ fn ensure_default_image(
627 default_raw,
602 default_raw,
628 &provision_actions,
603 &provision_actions,
629 directory_shares,
604 directory_shares,
630− DEFAULT_CPU_COUNT,
605+ BoxConfig::default().cpu_count,
631− DEFAULT_RAM_BYTES,
606+ BoxConfig::default().ram_size.as_u64(),
632− None,
607+ status,
633 )
608 )
634 };
609 };
635
610
@@ -644,18 +619,16 @@ fn ensure_default_image(
644fn ensure_instance_disk(
619fn ensure_instance_disk(
645 instance_raw: &Path,
620 instance_raw: &Path,
646 template_raw: &Path,
621 template_raw: &Path,
647− target_bytes: u64,
622+ target_bytes: ByteSize,
648− status: Option<&StatusFile>,
623+ status: Option<&StatusEmitter<'_>>,
649−) -> Result<bool, Box<dyn std::error::Error>> {
624+) -> Result<bool> {
650 if instance_raw.exists() {
625 if instance_raw.exists() {
651− let current_size = fs::metadata(instance_raw)?.len();
626+ let current_size = ByteSize(fs::metadata(instance_raw)?.len());
652 if current_size != target_bytes {
627 if current_size != target_bytes {
653− let current_gb = current_size as f64 / (1024.0 * 1024.0 * 1024.0);
628+ let current_gb = current_size;
654− let target_gb = target_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
629+ let target_gb = target_bytes;
655 tracing::warn!(
630 tracing::warn!(
656− current_bytes = current_size,
657− target_bytes,
658− "instance disk size does not match config (current {:.2} GB, config {:.2} GB); disk_gb applies only on init. Run `vibebox reset` to recreate or set disk_gb to match; using existing disk.",
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.",
659 current_gb,
632 current_gb,
660 target_gb
633 target_gb
661 );
634 );
@@ -663,30 +636,28 @@ fn ensure_instance_disk(
663 return Ok(false);
636 return Ok(false);
664 }
637 }
665
638
666− let template_size = fs::metadata(template_raw)?.len();
639+ let template_size = ByteSize(fs::metadata(template_raw)?.len());
667 if target_bytes < template_size {
640 if target_bytes < template_size {
668− return Err(format!(
641+ bail!(format!(
669− "Requested disk size {} bytes is smaller than base image size {} bytes",
642+ "Requested disk size {} is smaller than base image size {}",
670 target_bytes, template_size
643 target_bytes, template_size
671− )
644+ ));
672− .into());
673 }
645 }
674 let target_size = target_bytes;
646 let target_size = target_bytes;
675 let needs_resize = target_size > template_size;
647 let needs_resize = target_size > template_size;
676
648
677− if let Some(status) = status {
649+ emit_status(status, "creating instance disk...");
678− status.update("creating instance disk...");
650+ tracing::info!("creating instance disk...");
679− }
680 tracing::info!(path = %template_raw.display(), "creating instance disk");
651 tracing::info!(path = %template_raw.display(), "creating instance disk");
681− std::fs::create_dir_all(instance_raw.parent().unwrap())?;
652+ fs::create_dir_all(instance_raw.parent().unwrap())?;
682 if target_size == template_size {
653 if target_size == template_size {
683 fs::copy(template_raw, instance_raw)?;
654 fs::copy(template_raw, instance_raw)?;
684 return Ok(needs_resize);
655 return Ok(needs_resize);
685 }
656 }
686
657
687− let mut dst = std::fs::File::create(instance_raw)?;
658+ let mut dst = fs::File::create(instance_raw)?;
688− dst.set_len(target_size)?;
659+ dst.set_len(target_size.as_u64())?;
689− let mut src = std::fs::File::open(template_raw)?;
660+ let mut src = fs::File::open(template_raw)?;
690 std::io::copy(&mut src, &mut dst)?;
661 std::io::copy(&mut src, &mut dst)?;
691 Ok(needs_resize)
662 Ok(needs_resize)
692}
663}
@@ -704,18 +675,17 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
704 (read_stream.into(), write_stream.into())
675 (read_stream.into(), write_stream.into())
705}
676}
706
677
707−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+>(
708 output_monitor: Arc<OutputMonitor>,
682 output_monitor: Arc<OutputMonitor>,
709 vm_output_fd: OwnedFd,
683 vm_output_fd: OwnedFd,
710 vm_input_fd: OwnedFd,
684 vm_input_fd: OwnedFd,
711 io_control: Arc<IoControl>,
685 io_control: Arc<IoControl>,
712 mut on_line: F,
686 mut on_line: F,
713 mut on_output: G,
687 mut on_output: G,
714−) -> IoContext
688+) -> IoContext {
715−where
716− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
717− G: FnMut(&[u8]) + ::std::marker::Send + 'static,
718−{
719 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
689 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
720
690
721 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
691 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -863,14 +833,14 @@ where
863 PollResult::Spurious => continue,
833 PollResult::Spurious => continue,
864 PollResult::Ready(bytes) => {
834 PollResult::Ready(bytes) => {
865 if io_control.forward_output() {
835 if io_control.forward_output() {
866− // enable raw mode, if we haven't already
836+ // enable raw mode if we haven't already
867 if raw_guard.lock().unwrap().is_none()
837 if raw_guard.lock().unwrap().is_none()
868 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
838 && let Ok(guard) = enable_raw_mode(libc::STDIN_FILENO)
869 {
839 {
870 *raw_guard.lock().unwrap() = Some(guard);
840 *raw_guard.lock().unwrap() = Some(guard);
871 }
841 }
872
842
873− let mut stdout = std::io::stdout().lock();
843+ let mut stdout = io::stdout().lock();
874 if stdout.write_all(bytes).is_err() {
844 if stdout.write_all(bytes).is_err() {
875 break;
845 break;
876 }
846 }
@@ -886,7 +856,7 @@ where
886
856
887 // 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.
888 let mux_thread = thread::spawn(move || {
858 let mux_thread = thread::spawn(move || {
889− let mut vm_writer = std::fs::File::from(vm_input_fd);
859+ let mut vm_writer = fs::File::from(vm_input_fd);
890 loop {
860 loop {
891 match input_rx.recv() {
861 match input_rx.recv() {
892 Ok(VmInput::Bytes(data)) => {
862 Ok(VmInput::Bytes(data)) => {
@@ -916,7 +886,7 @@ pub fn spawn_vm_io_with_line_handler<F>(
916 on_line: F,
886 on_line: F,
917) -> IoContext
887) -> IoContext
918where
888where
919− F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
889+ F: FnMut(&str) -> bool + std::marker::Send + 'static,
920{
890{
921 spawn_vm_io_with_hooks(
891 spawn_vm_io_with_hooks(
922 output_monitor,
892 output_monitor,
@@ -983,7 +953,7 @@ fn create_vm_configuration(
983 vm_writes_to_fd: OwnedFd,
953 vm_writes_to_fd: OwnedFd,
984 cpu_count: usize,
954 cpu_count: usize,
985 ram_bytes: u64,
955 ram_bytes: u64,
986−) -> Result<Retained<VZVirtualMachineConfiguration>, Box<dyn std::error::Error>> {
956+) -> Result<Retained<VZVirtualMachineConfiguration>> {
987 unsafe {
957 unsafe {
988 let platform =
958 let platform =
989 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
959 VZGenericPlatformConfiguration::init(VZGenericPlatformConfiguration::alloc());
@@ -1017,7 +987,7 @@ fn create_vm_configuration(
1017 false,
987 false,
1018 VZDiskImageCachingMode::Automatic,
988 VZDiskImageCachingMode::Automatic,
1019 VZDiskImageSynchronizationMode::Full,
989 VZDiskImageSynchronizationMode::Full,
1020− ).unwrap();
990+ )?;
1021
991
1022 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
992 let disk_device = VZVirtioBlockDeviceConfiguration::initWithAttachment(
1023 VZVirtioBlockDeviceConfiguration::alloc(),
993 VZVirtioBlockDeviceConfiguration::alloc(),
@@ -1104,7 +1074,7 @@ fn create_vm_configuration(
1104 // Validate
1074 // Validate
1105 config.validateWithError().map_err(|e| {
1075 config.validateWithError().map_err(|e| {
1106 io::Error::other(format!(
1076 io::Error::other(format!(
1107− "Invalid VM configuration: {:?}",
1077+ "invalid VM configuration: {:?}",
1108 e.localizedDescription()
1078 e.localizedDescription()
1109 ))
1079 ))
1110 })?;
1080 })?;
@@ -1113,9 +1083,9 @@ fn create_vm_configuration(
1113 }
1083 }
1114}
1084}
1115
1085
1116−fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std::error::Error>> {
1086+fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>> {
1117 unsafe {
1087 unsafe {
1118− let temp_dir = std::env::temp_dir();
1088+ let temp_dir = env::temp_dir();
1119 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()));
1120 let url = nsurl_from_path(&temp_path)?;
1090 let url = nsurl_from_path(&temp_path)?;
1121 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
1091 let options = VZEFIVariableStoreInitializationOptions::AllowOverwrite;
@@ -1131,8 +1101,8 @@ fn load_efi_variable_store() -> Result<Retained<VZEFIVariableStore>, Box<dyn std
1131fn spawn_login_actions_thread(
1101fn spawn_login_actions_thread(
1132 login_actions: Vec<LoginAction>,
1102 login_actions: Vec<LoginAction>,
1133 output_monitor: Arc<OutputMonitor>,
1103 output_monitor: Arc<OutputMonitor>,
1134− input_tx: mpsc::Sender<VmInput>,
1104+ input_tx: Sender<VmInput>,
1135− vm_output_tx: mpsc::Sender<VmOutput>,
1105+ vm_output_tx: Sender<VmOutput>,
1136) -> thread::JoinHandle<()> {
1106) -> thread::JoinHandle<()> {
1137 thread::spawn(move || {
1107 thread::spawn(move || {
1138 for a in login_actions {
1108 for a in login_actions {
@@ -1182,9 +1152,9 @@ fn run_vm_with_io<F>(
1182 directory_shares: &[DirectoryShare],
1152 directory_shares: &[DirectoryShare],
1183 cpu_count: usize,
1153 cpu_count: usize,
1184 ram_bytes: u64,
1154 ram_bytes: u64,
1185− status: Option<&StatusFile>,
1155+ status: Option<&StatusEmitter<'_>>,
1186 io_handler: F,
1156 io_handler: F,
1187−) -> Result<(), Box<dyn std::error::Error>>
1157+) -> Result<()>
1188where
1158where
1189 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1159 F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1190{
1160{
@@ -1231,23 +1201,22 @@ where
1231
1201
1232 match rx.try_recv() {
1202 match rx.try_recv() {
1233 Ok(result) => {
1203 Ok(result) => {
1234− result.map_err(|e| format!("Failed to start VM: {}", e))?;
1204+ result.map_err(|e| Error::msg(format!("Failed to start VM: {}", e)))?;
1235 break;
1205 break;
1236 }
1206 }
1237 Err(mpsc::TryRecvError::Empty) => continue,
1207 Err(mpsc::TryRecvError::Empty) => continue,
1238 Err(mpsc::TryRecvError::Disconnected) => {
1208 Err(mpsc::TryRecvError::Disconnected) => {
1239− return Err("VM start channel disconnected".into());
1209+ bail!("VM start channel disconnected");
1240 }
1210 }
1241 }
1211 }
1242 }
1212 }
1243
1213
1244 if Instant::now() >= start_deadline {
1214 if Instant::now() >= start_deadline {
1245− return Err("Timed out waiting for VM to start".into());
1215+ bail!("Timed out waiting for VM to start");
1246 }
1216 }
1247
1217
1248− if let Some(status) = status {
1218+ emit_status(status, "vm booting... go vibecoder!");
1249− status.update("vm booting... go vibecoder!");
1219+ tracing::info!("vm booting... go vibecoder!");
1250− }
1251 tracing::info!("vm booting");
1220 tracing::info!("vm booting");
1252
1221
1253 let output_monitor = Arc::new(OutputMonitor::default());
1222 let output_monitor = Arc::new(OutputMonitor::default());
@@ -1293,7 +1262,7 @@ where
1293 );
1262 );
1294
1263
1295 let mut last_state = None;
1264 let mut last_state = None;
1296− let mut exit_result = Ok(());
1265+ let mut exit_result: Result<(), String> = Ok(());
1297 loop {
1266 loop {
1298 unsafe {
1267 unsafe {
1299 NSRunLoop::mainRunLoop().runMode_beforeDate(
1268 NSRunLoop::mainRunLoop().runMode_beforeDate(
@@ -1312,8 +1281,7 @@ where
1312 exit_result = Err(format!(
1281 exit_result = Err(format!(
1313 "Login action ({}) timed out after {:?}; shutting down.",
1282 "Login action ({}) timed out after {:?}; shutting down.",
1314 action, timeout
1283 action, timeout
1315− )
1284+ ));
1316− .into());
1317 unsafe {
1285 unsafe {
1318 if vm.canRequestStop() {
1286 if vm.canRequestStop() {
1319 if let Err(err) = vm.requestStopWithError() {
1287 if let Err(err) = vm.requestStopWithError() {
@@ -1330,8 +1298,7 @@ where
1330 exit_result = Err(format!(
1298 exit_result = Err(format!(
1331 "Login action ({}) failed: {}; shutting down.",
1299 "Login action ({}) failed: {}; shutting down.",
1332 action, reason
1300 action, reason
1333− )
1301+ ));
1334− .into());
1335 unsafe {
1302 unsafe {
1336 if vm.canRequestStop() {
1303 if vm.canRequestStop() {
1337 if let Err(err) = vm.requestStopWithError() {
1304 if let Err(err) = vm.requestStopWithError() {
@@ -1347,7 +1314,7 @@ where
1347 Err(mpsc::TryRecvError::Empty) => {}
1314 Err(mpsc::TryRecvError::Empty) => {}
1348 Err(mpsc::TryRecvError::Disconnected) => {}
1315 Err(mpsc::TryRecvError::Disconnected) => {}
1349 }
1316 }
1350− if state != objc2_virtualization::VZVirtualMachineState::Running {
1317+ if state != VZVirtualMachineState::Running {
1351 //eprintln!("VM stopped with state: {:?}", state);
1318 //eprintln!("VM stopped with state: {:?}", state);
1352 break;
1319 break;
1353 }
1320 }
@@ -1357,7 +1324,7 @@ where
1357
1324
1358 io_ctx.shutdown();
1325 io_ctx.shutdown();
1359
1326
1360− exit_result
1327+ exit_result.map_err(Error::msg)
1361}
1328}
1362
1329
1363fn run_vm(
1330fn run_vm(
@@ -1366,8 +1333,8 @@ fn run_vm(
1366 directory_shares: &[DirectoryShare],
1333 directory_shares: &[DirectoryShare],
1367 cpu_count: usize,
1334 cpu_count: usize,
1368 ram_bytes: u64,
1335 ram_bytes: u64,
1369− status: Option<&StatusFile>,
1336+ status: Option<&StatusEmitter<'_>>,
1370−) -> Result<(), Box<dyn std::error::Error>> {
1337+) -> Result<()> {
1371 run_vm_with_io(
1338 run_vm_with_io(
1372 disk_path,
1339 disk_path,
1373 login_actions,
1340 login_actions,
@@ -1379,7 +1346,7 @@ fn run_vm(
1379 )
1346 )
1380}
1347}
1381
1348
1382−fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1349+fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>> {
1383 let abs_path = if path.is_absolute() {
1350 let abs_path = if path.is_absolute() {
1384 path.to_path_buf()
1351 path.to_path_buf()
1385 } else {
1352 } else {
@@ -1388,7 +1355,7 @@ fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::E
1388 let ns_path = NSString::from_str(
1355 let ns_path = NSString::from_str(
1389 abs_path
1356 abs_path
1390 .to_str()
1357 .to_str()
1391− .ok_or("Non-UTF8 path encountered while building NSURL")?,
1358+ .with_context(|| "non-UTF8 path encountered while building NSURL")?,
1392 );
1359 );
1393 Ok(NSURL::fileURLWithPath(&ns_path))
1360 Ok(NSURL::fileURLWithPath(&ns_path))
1394}
1361}
@@ -1403,7 +1370,7 @@ fn enable_raw_mode(fd: i32) -> io::Result<RawModeGuard> {
1403 let original = attributes;
1370 let original = attributes;
1404
1371
1405 // Disable translation of carriage return to newline on input
1372 // Disable translation of carriage return to newline on input
1406− attributes.c_iflag &= !(libc::ICRNL);
1373+ attributes.c_iflag &= !libc::ICRNL;
1407 // Disable canonical mode (line buffering), echo, and signal generation
1374 // Disable canonical mode (line buffering), echo, and signal generation
1408 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1375 attributes.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG);
1409 attributes.c_cc[libc::VMIN] = 0;
1376 attributes.c_cc[libc::VMIN] = 0;
@@ -1431,10 +1398,10 @@ impl Drop for RawModeGuard {
1431
1398
1432// 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.
1433pub fn ensure_signed() {
1400pub fn ensure_signed() {
1434− if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1401+ if env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1435 return;
1402 return;
1436 }
1403 }
1437− 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");
1438 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");
1439
1406
1440 let has_required_entitlements = {
1407 let has_required_entitlements = {
@@ -1456,8 +1423,8 @@ pub fn ensure_signed() {
1456 }
1423 }
1457
1424
1458 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1425 const ENTITLEMENTS: &str = include_str!("entitlements.plist");
1459− let entitlements_path = std::env::temp_dir().join("entitlements.plist");
1426+ let entitlements_path = env::temp_dir().join("entitlements.plist");
1460− std::fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1427+ fs::write(&entitlements_path, ENTITLEMENTS).expect("failed to write entitlements");
1461
1428
1462 let output = Command::new("codesign")
1429 let output = Command::new("codesign")
1463 .args([
1430 .args([
@@ -1470,7 +1437,7 @@ pub fn ensure_signed() {
1470 ])
1437 ])
1471 .output();
1438 .output();
1472
1439
1473− let _ = std::fs::remove_file(&entitlements_path);
1440+ let _ = fs::remove_file(&entitlements_path);
1474
1441
1475 match output {
1442 match output {
1476 Ok(o) if o.status.success() => {
1443 Ok(o) if o.status.success() => {
@@ -1478,7 +1445,7 @@ pub fn ensure_signed() {
1478 if !stderr.trim().is_empty() {
1445 if !stderr.trim().is_empty() {
1479 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1446 tracing::debug!(codesign_stderr = %stderr.trim(), "codesign output");
1480 }
1447 }
1481− 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();
1482 tracing::error!(error = %err, "failed to re-exec after signing");
1449 tracing::error!(error = %err, "failed to re-exec after signing");
1483 std::process::exit(1);
1450 std::process::exit(1);
1484 }
1451 }
src/vm_manager.rs+204−114
@@ -1,12 +1,22 @@
1+use crate::instance::{VmLiveness, vm_liveness};
2+use crate::session_manager::INSTANCE_DIR_NAME;
3+use crate::{
4+ config::CONFIG_PATH_ENV,
5+ instance::{
6+ InstanceConfig, STATUS_VM_ERROR_PREFIX, build_ssh_login_actions, ensure_instance_dir,
7+ ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
8+ },
9+ session_manager::{GLOBAL_DIR_NAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME},
10+ vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
11+};
12+use anyhow::{Error, Result, anyhow, bail};
1use std::{
13use std::{
2 env, fs,
14 env, fs,
3 io::{Read, Write},
15 io::{Read, Write},
4 os::unix::{
16 os::unix::{
5− fs::FileTypeExt,
6 fs::PermissionsExt,
17 fs::PermissionsExt,
7 io::AsRawFd,
18 io::AsRawFd,
8 net::{UnixListener, UnixStream},
19 net::{UnixListener, UnixStream},
9− process::CommandExt,
10 },
20 },
11 path::{Path, PathBuf},
21 path::{Path, PathBuf},
12 process::{Command, Stdio},
22 process::{Command, Stdio},
@@ -15,20 +25,8 @@ use std::{
15 time::{Duration, Instant},
25 time::{Duration, Instant},
16};
26};
17
27
18−use crate::{
28+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
19− config::CONFIG_PATH_ENV,
29+const VM_ROOT_LOG_NAME: &str = "vm_root.log";
20− instance::STATUS_FILE_NAME,
21− instance::VM_ROOT_LOG_NAME,
22− instance::{
23− DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir,
24− ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
25− },
26− session_manager::{
27− GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
28− },
29− vm::{self, DirectoryShare, LoginAction, PROJECT_GUEST_BASE, VmInput},
30−};
31−
32const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
30const VM_MANAGER_LOCK_NAME: &str = "vm.lock";
33const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
31const VM_MANAGER_LOG_NAME: &str = "vm_manager.log";
34const SHUTDOWN_RETRY_MS: u64 = 500;
32const SHUTDOWN_RETRY_MS: u64 = 500;
@@ -36,6 +34,10 @@ const SHUTDOWN_RETRY_MS: u64 = 500;
36const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
34const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 1_000;
37#[cfg(not(test))]
35#[cfg(not(test))]
38const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
36const HARD_SHUTDOWN_TIMEOUT_MS: u64 = 12_000;
37+const STATUS_PREFIX: &str = "status:";
38+
39+type ClientStreams = Arc<Mutex<Vec<UnixStream>>>;
40+type SharedStatus = Arc<Mutex<String>>;
39
41
40#[cfg(not(test))]
42#[cfg(not(test))]
41fn force_exit(_reason: &str) -> ! {
43fn force_exit(_reason: &str) -> ! {
@@ -51,16 +53,16 @@ pub fn ensure_manager(
51 raw_args: &[std::ffi::OsString],
53 raw_args: &[std::ffi::OsString],
52 auto_shutdown_ms: u64,
54 auto_shutdown_ms: u64,
53 config_path: Option<&Path>,
55 config_path: Option<&Path>,
54−) -> Result<UnixStream, Box<dyn std::error::Error>> {
56+) -> Result<UnixStream> {
55 let project_root = env::current_dir()?;
57 let project_root = env::current_dir()?;
56 tracing::debug!(root = %project_root.display(), "ensure vm manager");
58 tracing::debug!(root = %project_root.display(), "ensure vm manager");
57 let instance_dir = ensure_instance_dir(&project_root)?;
59 let instance_dir = ensure_instance_dir(&project_root)?;
58− cleanup_stale_manager(&instance_dir);
60+ cleanup_stale_manager(&project_root)?;
59 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
61 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
60
62
61 if let Ok(stream) = UnixStream::connect(&socket_path) {
63 if let Ok(stream) = UnixStream::connect(&socket_path) {
62 send_client_pid(&stream);
64 send_client_pid(&stream);
63− tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
65+ tracing::info!(path = %socket_path.display(), "connected to an existing vm manager");
64 return Ok(stream);
66 return Ok(stream);
65 }
67 }
66
68
@@ -98,12 +100,11 @@ pub fn ensure_manager(
98 drop(lock_file.take());
100 drop(lock_file.take());
99 let _ = fs::remove_file(&lock_path);
101 let _ = fs::remove_file(&lock_path);
100 }
102 }
101− return Err(format!(
103+ bail!(format!(
102− "Timed out waiting for vm manager socket: {} ({})",
104+ "timed out waiting for vm manager socket: {} ({})",
103 socket_path.display(),
105 socket_path.display(),
104 err
106 err
105− )
107+ ));
106− .into());
107 }
108 }
108 thread::sleep(Duration::from_millis(100));
109 thread::sleep(Duration::from_millis(100));
109 }
110 }
@@ -111,12 +112,19 @@ pub fn ensure_manager(
111 }
112 }
112}
113}
113
114
114−pub fn run_manager(
115− args: vm::VmArg,
116− auto_shutdown_ms: u64,
115+pub fn run_manager(args: vm::VmArg, auto_shutdown_ms: u64) -> Result<()> {
117−) -> Result<(), Box<dyn std::error::Error>> {
118 let project_root = env::current_dir()?;
116 let project_root = env::current_dir()?;
119 tracing::info!(root = %project_root.display(), "vm manager starting");
117 tracing::info!(root = %project_root.display(), "vm manager starting");
118+ #[cfg(not(feature = "mock-vm"))]
119+ {
120+ unsafe {
121+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
122+ }
123+ vm::ensure_signed();
124+ unsafe {
125+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
126+ }
127+ }
120 let _pid_guard = ensure_pid_file(&project_root)?;
128 let _pid_guard = ensure_pid_file(&project_root)?;
121 #[cfg(feature = "mock-vm")]
129 #[cfg(feature = "mock-vm")]
122 tracing::info!("vm manager using mock executor");
130 tracing::info!("vm manager using mock executor");
@@ -134,7 +142,6 @@ pub fn run_manager(
134 #[cfg(feature = "mock-vm")]
142 #[cfg(feature = "mock-vm")]
135 {
143 {
136 ManagerOptions {
144 ManagerOptions {
137− ensure_signed: false,
138 detach: true,
145 detach: true,
139 prepare_vm: false,
146 prepare_vm: false,
140 }
147 }
@@ -142,7 +149,6 @@ pub fn run_manager(
142 #[cfg(not(feature = "mock-vm"))]
149 #[cfg(not(feature = "mock-vm"))]
143 {
150 {
144 ManagerOptions {
151 ManagerOptions {
145− ensure_signed: true,
146 detach: true,
152 detach: true,
147 prepare_vm: true,
153 prepare_vm: true,
148 }
154 }
@@ -156,25 +162,22 @@ fn spawn_manager_process(
156 auto_shutdown_ms: u64,
162 auto_shutdown_ms: u64,
157 instance_dir: &Path,
163 instance_dir: &Path,
158 config_path: Option<&Path>,
164 config_path: Option<&Path>,
159−) -> Result<(), Box<dyn std::error::Error>> {
165+) -> Result<()> {
160 let exe = env::current_exe()?;
166 let exe = env::current_exe()?;
161 let mut supervisor_exe = exe.clone();
167 let mut supervisor_exe = exe.clone();
162 supervisor_exe.set_file_name("vibebox-supervisor");
168 supervisor_exe.set_file_name("vibebox-supervisor");
163− let use_supervisor = supervisor_exe.exists();
169+ // intentional
164− let mut cmd = if use_supervisor {
170+ if !supervisor_exe.exists() {
165− Command::new(supervisor_exe)
171+ bail!(format!(
166− } else {
167− let mut cmd = Command::new(exe);
168− cmd.arg0("vibebox-supervisor");
172+ "vibebox-supervisor not found at {}",
169− cmd
173+ supervisor_exe.display()
174+ ));
170− };
175+ }
176+ let mut cmd = Command::new(supervisor_exe);
171 if raw_args.len() > 1 {
177 if raw_args.len() > 1 {
172 cmd.args(&raw_args[1..]);
178 cmd.args(&raw_args[1..]);
173 }
179 }
174 cmd.env("VIBEBOX_INTERNAL", "1");
180 cmd.env("VIBEBOX_INTERNAL", "1");
175− if !use_supervisor {
176− cmd.env("VIBEBOX_VM_MANAGER", "1");
177− }
178 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
181 cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
179 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
182 cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
180 if let Some(path) = config_path {
183 if let Some(path) = config_path {
@@ -202,22 +205,22 @@ fn spawn_manager_process(
202 Ok(())
205 Ok(())
203}
206}
204
207
205−fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::error::Error>> {
208+fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard> {
206 let instance_dir = ensure_instance_dir(project_root)?;
209 let instance_dir = ensure_instance_dir(project_root)?;
207 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
210 let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
208 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
211 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
209− if let Ok(content) = fs::read_to_string(&pid_path)
212+ match vm_liveness(project_root)? {
210− && let Ok(pid) = content.trim().parse::<u32>()
213+ VmLiveness::RunningWithSocket { pid } => {
211− && pid_is_alive(pid)
212− {
213− if is_socket_path(&socket_path) {
214− return Err(format!("vm manager already running (pid {pid})").into());
214+ bail!("vm manager already running (pid {pid})");
215 }
215 }
216+ VmLiveness::RunningWithoutSocket { pid } => {
216− tracing::warn!(
217+ tracing::warn!(
217− pid,
218+ pid,
218− path = %socket_path.display(),
219+ path = %socket_path.display(),
219− "stale pid file detected with missing socket"
220+ "stale pid file detected with missing socket"
220− );
221+ );
222+ }
223+ VmLiveness::NotRunningOrMissing => {}
221 }
224 }
222 let _ = fs::remove_file(&pid_path);
225 let _ = fs::remove_file(&pid_path);
223 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
226 fs::write(&pid_path, format!("{}\n", std::process::id()))?;
@@ -225,15 +228,18 @@ fn ensure_pid_file(project_root: &Path) -> Result<PidFileGuard, Box<dyn std::err
225 Ok(PidFileGuard { path: pid_path })
228 Ok(PidFileGuard { path: pid_path })
226}
229}
227
230
228−fn cleanup_stale_manager(instance_dir: &Path) {
231+fn cleanup_stale_manager(project_root: &Path) -> Result<()> {
232+ let pid_path = project_root
233+ .join(INSTANCE_DIR_NAME)
229− let pid_path = instance_dir.join(VM_MANAGER_PID_NAME);
234+ .join(VM_MANAGER_PID_NAME);
230− if let Ok(content) = fs::read_to_string(&pid_path)
235+ if matches!(
231− && let Ok(pid) = content.trim().parse::<u32>()
236+ vm_liveness(project_root)?,
232− && pid_is_alive(pid)
237+ VmLiveness::RunningWithSocket { .. } | VmLiveness::RunningWithoutSocket { .. }
233− {
238+ ) {
234− return;
239+ return Ok(());
235 }
240 }
236 let _ = fs::remove_file(&pid_path);
241 let _ = fs::remove_file(&pid_path);
242+ Ok(())
237}
243}
238
244
239fn inject_project_mount(
245fn inject_project_mount(
@@ -260,12 +266,6 @@ fn inject_project_mount(
260 mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
266 mounts.insert(0, format!("{host}:{guest_tilde}:read-write"));
261}
267}
262
268
263−fn is_socket_path(path: &Path) -> bool {
264− fs::metadata(path)
265− .map(|meta| meta.file_type().is_socket())
266− .unwrap_or(false)
267−}
268−
269fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
269fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
270 let mut links = Vec::new();
270 let mut links = Vec::new();
271 let mut mounts = Vec::with_capacity(args.mounts.len());
271 let mut mounts = Vec::with_capacity(args.mounts.len());
@@ -409,6 +409,30 @@ fn wait_for_disconnect(mut stream: UnixStream) {
409 }
409 }
410}
410}
411
411
412+fn remove_client(streams: &ClientStreams, fd: std::os::fd::RawFd) {
413+ if let Ok(mut clients) = streams.lock() {
414+ clients.retain(|stream| stream.as_raw_fd() != fd);
415+ }
416+}
417+
418+fn send_status_line(stream: &mut UnixStream, status: &str) -> bool {
419+ let mut payload = String::with_capacity(STATUS_PREFIX.len() + status.len() + 1);
420+ payload.push_str(STATUS_PREFIX);
421+ payload.push_str(status);
422+ payload.push('\n');
423+ stream.write_all(payload.as_bytes()).is_ok()
424+}
425+
426+#[cfg_attr(feature = "mock-vm", allow(dead_code))]
427+fn broadcast_status(streams: &ClientStreams, latest_status: &SharedStatus, status: &str) {
428+ if let Ok(mut current) = latest_status.lock() {
429+ *current = status.to_string();
430+ }
431+ if let Ok(mut clients) = streams.lock() {
432+ clients.retain_mut(|stream| send_status_line(stream, status));
433+ }
434+}
435+
412fn send_client_pid(stream: &UnixStream) {
436fn send_client_pid(stream: &UnixStream) {
413 let pid = std::process::id();
437 let pid = std::process::id();
414 let payload = format!("pid={pid}\n");
438 let payload = format!("pid={pid}\n");
@@ -418,7 +442,7 @@ fn send_client_pid(stream: &UnixStream) {
418 }
442 }
419}
443}
420
444
421−fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>, Box<dyn std::error::Error>> {
445+fn acquire_spawn_lock(lock_path: &Path) -> Result<Option<fs::File>> {
422 match fs::OpenOptions::new()
446 match fs::OpenOptions::new()
423 .write(true)
447 .write(true)
424 .create_new(true)
448 .create_new(true)
@@ -452,6 +476,12 @@ fn is_lock_stale(lock_path: &Path) -> bool {
452 }
476 }
453}
477}
454
478
479+fn read_lock_pid(lock_path: &Path) -> Option<u32> {
480+ let content = fs::read_to_string(lock_path).ok()?;
481+ let line = content.lines().next()?;
482+ line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
483+}
484+
455fn pid_is_alive(pid: u32) -> bool {
485fn pid_is_alive(pid: u32) -> bool {
456 let pid = pid as libc::pid_t;
486 let pid = pid as libc::pid_t;
457 let result = unsafe { libc::kill(pid, 0) };
487 let result = unsafe { libc::kill(pid, 0) };
@@ -465,12 +495,6 @@ fn pid_is_alive(pid: u32) -> bool {
465 }
495 }
466}
496}
467
497
468−fn read_lock_pid(lock_path: &Path) -> Option<u32> {
469− let content = fs::read_to_string(lock_path).ok()?;
470− let line = content.lines().next()?;
471− line.strip_prefix("pid=")?.trim().parse::<u32>().ok()
472−}
473−
474fn read_client_pid(stream: &UnixStream) -> Option<u32> {
498fn read_client_pid(stream: &UnixStream) -> Option<u32> {
475 let mut stream = stream.try_clone().ok()?;
499 let mut stream = stream.try_clone().ok()?;
476 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
500 let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
@@ -506,12 +530,14 @@ fn read_client_pid(stream: &UnixStream) -> Option<u32> {
506#[cfg_attr(feature = "mock-vm", allow(dead_code))]
530#[cfg_attr(feature = "mock-vm", allow(dead_code))]
507fn spawn_manager_io(
531fn spawn_manager_io(
508 config: Arc<Mutex<InstanceConfig>>,
532 config: Arc<Mutex<InstanceConfig>>,
509− instance_dir: PathBuf,
533+ project_dir: PathBuf,
534+ clients: ClientStreams,
535+ latest_status: SharedStatus,
510 output_monitor: Arc<vm::OutputMonitor>,
536 output_monitor: Arc<vm::OutputMonitor>,
511 vm_output_fd: std::os::unix::io::OwnedFd,
537 vm_output_fd: std::os::unix::io::OwnedFd,
512 vm_input_fd: std::os::unix::io::OwnedFd,
538 vm_input_fd: std::os::unix::io::OwnedFd,
513) -> vm::IoContext {
539) -> vm::IoContext {
514− let log_path = instance_dir.join(VM_ROOT_LOG_NAME);
540+ let log_path = project_dir.join(INSTANCE_DIR_NAME).join(VM_ROOT_LOG_NAME);
515 let log_file = fs::OpenOptions::new()
541 let log_file = fs::OpenOptions::new()
516 .create(true)
542 .create(true)
517 .write(true)
543 .write(true)
@@ -520,7 +546,6 @@ fn spawn_manager_io(
520 .ok()
546 .ok()
521 .map(|file| Arc::new(Mutex::new(file)));
547 .map(|file| Arc::new(Mutex::new(file)));
522
548
523− let instance_path = instance_dir.join(INSTANCE_FILENAME);
524 let config_for_output = config.clone();
549 let config_for_output = config.clone();
525 let log_for_output = log_file.clone();
550 let log_for_output = log_file.clone();
526 let mut line_buf = String::new();
551 let mut line_buf = String::new();
@@ -543,6 +568,17 @@ fn spawn_manager_io(
543 line_buf.drain(..=pos);
568 line_buf.drain(..=pos);
544
569
545 let cleaned = line.trim_start_matches(['\r', ' ']);
570 let cleaned = line.trim_start_matches(['\r', ' ']);
571+ if let Some(script_failure) = cleaned.strip_prefix("VIBEBOX_SCRIPT_ERROR:") {
572+ let failure = script_failure.trim();
573+ if !failure.is_empty() {
574+ tracing::error!(script_failure = %failure, "[vm] script reported failure");
575+ broadcast_status(
576+ &clients,
577+ &latest_status,
578+ &format!("{STATUS_VM_ERROR_PREFIX} {failure}"),
579+ );
580+ }
581+ }
546 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
582 if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
547 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
583 let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
548 let ip = extract_ipv4(ip_raw).unwrap_or_default();
584 let ip = extract_ipv4(ip_raw).unwrap_or_default();
@@ -551,7 +587,7 @@ fn spawn_manager_io(
551 && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
587 && cfg.vm_ipv4.as_deref() != Some(ip.as_str())
552 {
588 {
553 cfg.vm_ipv4 = Some(ip.clone());
589 cfg.vm_ipv4 = Some(ip.clone());
554− let _ = write_instance_config(&instance_path, &cfg);
590+ let _ = write_instance_config(&project_dir, &cfg);
555 }
591 }
556 }
592 }
557 }
593 }
@@ -574,12 +610,12 @@ enum ManagerEvent {
574}
610}
575
611
576struct ManagerOptions {
612struct ManagerOptions {
577− ensure_signed: bool,
578 detach: bool,
613 detach: bool,
579 prepare_vm: bool,
614 prepare_vm: bool,
580}
615}
581
616
582trait VmExecutor {
617trait VmExecutor {
618+ #[allow(clippy::too_many_arguments)]
583 fn run_vm(
619 fn run_vm(
584 &self,
620 &self,
585 args: vm::VmArg,
621 args: vm::VmArg,
@@ -587,8 +623,10 @@ trait VmExecutor {
587 extra_shares: Vec<DirectoryShare>,
623 extra_shares: Vec<DirectoryShare>,
588 config: Arc<Mutex<InstanceConfig>>,
624 config: Arc<Mutex<InstanceConfig>>,
589 instance_dir: PathBuf,
625 instance_dir: PathBuf,
626+ clients: ClientStreams,
627+ latest_status: SharedStatus,
590 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
628 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
591− ) -> Result<(), Box<dyn std::error::Error>>;
629+ ) -> Result<()>;
592}
630}
593
631
594#[cfg_attr(feature = "mock-vm", allow(dead_code))]
632#[cfg_attr(feature = "mock-vm", allow(dead_code))]
@@ -601,15 +639,22 @@ impl VmExecutor for RealVmExecutor {
601 extra_login_actions: Vec<LoginAction>,
639 extra_login_actions: Vec<LoginAction>,
602 extra_shares: Vec<DirectoryShare>,
640 extra_shares: Vec<DirectoryShare>,
603 config: Arc<Mutex<InstanceConfig>>,
641 config: Arc<Mutex<InstanceConfig>>,
604− instance_dir: PathBuf,
642+ project_dir: PathBuf,
643+ clients: ClientStreams,
644+ latest_status: SharedStatus,
605 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
645 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
606− ) -> Result<(), Box<dyn std::error::Error>> {
646+ ) -> Result<()> {
647+ let status_callback = |status: &str| {
648+ broadcast_status(&clients, &latest_status, status);
649+ };
607 vm::run_with_args_and_extras(
650 vm::run_with_args_and_extras(
608 args,
651 args,
609 |output_monitor, vm_output_fd, vm_input_fd| {
652 |output_monitor, vm_output_fd, vm_input_fd| {
610 let io_ctx = spawn_manager_io(
653 let io_ctx = spawn_manager_io(
611 config.clone(),
654 config.clone(),
612− instance_dir.clone(),
655+ project_dir.clone(),
656+ clients.clone(),
657+ latest_status.clone(),
613 output_monitor,
658 output_monitor,
614 vm_output_fd,
659 vm_output_fd,
615 vm_input_fd,
660 vm_input_fd,
@@ -619,6 +664,7 @@ impl VmExecutor for RealVmExecutor {
619 },
664 },
620 extra_login_actions,
665 extra_login_actions,
621 extra_shares,
666 extra_shares,
667+ Some(&status_callback),
622 )
668 )
623 }
669 }
624}
670}
@@ -635,8 +681,10 @@ impl VmExecutor for MockVmExecutor {
635 _extra_shares: Vec<DirectoryShare>,
681 _extra_shares: Vec<DirectoryShare>,
636 _config: Arc<Mutex<InstanceConfig>>,
682 _config: Arc<Mutex<InstanceConfig>>,
637 _instance_dir: PathBuf,
683 _instance_dir: PathBuf,
684+ _clients: ClientStreams,
685+ _latest_status: SharedStatus,
638 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
686 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
639− ) -> Result<(), Box<dyn std::error::Error>> {
687+ ) -> Result<()> {
640 let (tx, rx) = mpsc::channel::<VmInput>();
688 let (tx, rx) = mpsc::channel::<VmInput>();
641 *vm_input_tx.lock().unwrap() = Some(tx);
689 *vm_input_tx.lock().unwrap() = Some(tx);
642 tracing::info!("mock vm executor running");
690 tracing::info!("mock vm executor running");
@@ -662,24 +710,14 @@ fn run_manager_with(
662 auto_shutdown_ms: u64,
710 auto_shutdown_ms: u64,
663 executor: &dyn VmExecutor,
711 executor: &dyn VmExecutor,
664 options: ManagerOptions,
712 options: ManagerOptions,
665−) -> Result<(), Box<dyn std::error::Error>> {
713+) -> Result<()> {
666− if options.ensure_signed {
667− let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
668− unsafe {
669− env::remove_var("VIBEBOX_SKIP_CODESIGN");
670− }
671− vm::ensure_signed();
672− unsafe {
673− env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
674− }
675− }
676 if options.detach {
714 if options.detach {
677 detach_from_terminal();
715 detach_from_terminal();
678 }
716 }
679
717
680 let project_name = project_root
718 let project_name = project_root
681 .file_name()
719 .file_name()
682− .ok_or("Project directory has no name")?
720+ .ok_or_else(|| anyhow!("Project directory has no name"))?
683 .to_string_lossy()
721 .to_string_lossy()
684 .into_owned();
722 .into_owned();
685 let instance_dir = ensure_instance_dir(project_root)?;
723 let instance_dir = ensure_instance_dir(project_root)?;
@@ -687,16 +725,16 @@ fn run_manager_with(
687 let _ = ensure_ssh_keypair(&instance_dir)?;
725 let _ = ensure_ssh_keypair(&instance_dir)?;
688 }
726 }
689
727
690− let mut config = load_or_create_instance_config(&instance_dir)?;
728+ let mut config = load_or_create_instance_config(project_root)?;
691 if config.vm_ipv4.is_some() {
729 if config.vm_ipv4.is_some() {
692 config.vm_ipv4 = None;
730 config.vm_ipv4 = None;
693− write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
731+ write_instance_config(project_root, &config)?;
694 }
732 }
695 let config = Arc::new(Mutex::new(config));
733 let config = Arc::new(Mutex::new(config));
696 let ssh_user = config
734 let ssh_user = config
697 .lock()
735 .lock()
698− .map(|cfg| cfg.ssh_user_display())
736+ .map(|cfg| cfg.ssh_user.clone())
699− .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
737+ .map_err(|_| anyhow!("failed to acquire ssh user display"))?;
700 if !args.no_default_mounts {
738 if !args.no_default_mounts {
701 inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
739 inject_project_mount(&mut args.mounts, project_root, &ssh_user, &project_name);
702 }
740 }
@@ -704,11 +742,10 @@ fn run_manager_with(
704
742
705 let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
743 let project_guest_dir = format!("{PROJECT_GUEST_BASE}/{project_name}");
706 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
744 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
707− let extra_shares = vec![DirectoryShare::new(
745+ let extra_shares = vec![
708− instance_dir.clone(),
709− ssh_guest_dir.clone().into(),
746+ DirectoryShare::new(instance_dir.clone(), ssh_guest_dir.clone().into(), true)
710− true,
747+ .map_err(|err| anyhow!(err.to_string()))?,
711− )?];
748+ ];
712 let extra_login_actions = build_ssh_login_actions(
749 let extra_login_actions = build_ssh_login_actions(
713 &config,
750 &config,
714 &project_name,
751 &project_name,
@@ -731,17 +768,43 @@ fn run_manager_with(
731 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
768 let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
732 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
769 tracing::info!(path = %socket_path.display(), "vm manager socket bound");
733
770
771+ let clients: ClientStreams = Arc::new(Mutex::new(Vec::new()));
772+ let latest_status: SharedStatus = Arc::new(Mutex::new(String::new()));
734 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
773 let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
735 let event_tx_accept = event_tx.clone();
774 let event_tx_accept = event_tx.clone();
775+ let clients_accept = clients.clone();
776+ let latest_status_accept = latest_status.clone();
736 thread::spawn(move || {
777 thread::spawn(move || {
737 for stream in listener.incoming() {
778 for stream in listener.incoming() {
738 match stream {
779 match stream {
739 Ok(stream) => {
780 Ok(stream) => {
781+ let mut client_fd: Option<std::os::fd::RawFd> = None;
782+ let latest_status_snapshot = latest_status_accept
783+ .lock()
784+ .ok()
785+ .map(|status| status.clone());
786+ if let Ok(writer) = stream.try_clone() {
787+ let writer_fd = writer.as_raw_fd();
788+ if let Ok(mut connected) = clients_accept.lock() {
789+ connected.push(writer);
790+ client_fd = Some(writer_fd);
791+ if let Some(last) = connected.last_mut()
792+ && let Some(status) = latest_status_snapshot.as_deref()
793+ && !status.is_empty()
794+ {
795+ let _ = send_status_line(last, status);
796+ }
797+ }
798+ }
740 let event_tx_conn = event_tx_accept.clone();
799 let event_tx_conn = event_tx_accept.clone();
800+ let clients_conn = clients_accept.clone();
741 thread::spawn(move || {
801 thread::spawn(move || {
742 let pid = read_client_pid(&stream);
802 let pid = read_client_pid(&stream);
743 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
803 let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
744 wait_for_disconnect(stream);
804 wait_for_disconnect(stream);
805+ if let Some(fd) = client_fd {
806+ remove_client(&clients_conn, fd);
807+ }
745 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
808 let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
746 });
809 });
747 }
810 }
@@ -761,38 +824,43 @@ fn run_manager_with(
761 extra_login_actions,
824 extra_login_actions,
762 extra_shares,
825 extra_shares,
763 config.clone(),
826 config.clone(),
764− instance_dir.clone(),
827+ project_root.to_path_buf(),
828+ clients.clone(),
829+ latest_status.clone(),
765 vm_input_tx.clone(),
830 vm_input_tx.clone(),
766 );
831 );
767 tracing::info!("vm manager vm run completed");
832 tracing::info!("vm manager vm run completed");
768 let vm_err = vm_result.err().map(|e| e.to_string());
833 let vm_err = vm_result.err().map(|e| e.to_string());
769 if let Some(err) = &vm_err {
834 if let Some(err) = &vm_err {
770− let status_path = instance_dir.join(STATUS_FILE_NAME);
835+ broadcast_status(
771− let _ = fs::write(&status_path, format!("error: {err}"));
836+ &clients,
837+ &latest_status,
838+ &format!("{STATUS_VM_ERROR_PREFIX} {err}"),
839+ );
772 }
840 }
773 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
841 let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
774− let event_loop_result: Result<(), String> = event_loop_handle
842+ let event_loop_result = event_loop_handle
775 .join()
843 .join()
776− .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
844+ .unwrap_or_else(|_| Err(Error::msg("vm manager event loop panicked")))
777 .map_err(|err| err.to_string());
845 .map_err(|err| err.to_string());
778 let _ = fs::remove_file(&socket_path);
846 let _ = fs::remove_file(&socket_path);
779 if let Err(err) = &event_loop_result {
847 if let Err(err) = &event_loop_result {
780 tracing::error!(error = %err, "vm manager exiting due to event loop error");
848 tracing::error!(error = %err, "vm manager exiting due to event loop error");
781− return Err(err.to_string().into());
849+ bail!(err.to_string());
782 }
850 }
783 if let Some(err) = vm_err {
851 if let Some(err) = vm_err {
784 tracing::error!(error = %err, "vm manager exiting due to vm error");
852 tracing::error!(error = %err, "vm manager exiting due to vm error");
785− return Err(err.into());
853+ bail!(err);
786 }
854 }
787 tracing::info!("vm manager exiting");
855 tracing::info!("vm manager exiting");
788− Ok(event_loop_result?)
856+ event_loop_result.map_err(Error::msg)
789}
857}
790
858
791fn manager_event_loop(
859fn manager_event_loop(
792 event_rx: mpsc::Receiver<ManagerEvent>,
860 event_rx: mpsc::Receiver<ManagerEvent>,
793 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
861 vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
794 auto_shutdown_ms: u64,
862 auto_shutdown_ms: u64,
795−) -> Result<(), String> {
863+) -> Result<()> {
796 let mut ref_count: usize = 0;
864 let mut ref_count: usize = 0;
797 let mut shutdown_deadline: Option<Instant> = None;
865 let mut shutdown_deadline: Option<Instant> = None;
798 let mut shutdown_sent = false;
866 let mut shutdown_sent = false;
@@ -902,7 +970,7 @@ fn manager_event_loop(
902#[cfg(test)]
970#[cfg(test)]
903mod tests {
971mod tests {
904 use super::*;
972 use super::*;
905− use std::{sync::mpsc, thread, time::Duration};
973+ use std::{fs, sync::mpsc, thread, time::Duration};
906
974
907 #[test]
975 #[test]
908 fn manager_powers_off_after_grace_when_no_refs() {
976 fn manager_powers_off_after_grace_when_no_refs() {
@@ -984,4 +1052,26 @@ mod tests {
984 let _ = event_tx.send(ManagerEvent::VmExited(None));
1052 let _ = event_tx.send(ManagerEvent::VmExited(None));
985 let _ = manager_thread.join();
1053 let _ = manager_thread.join();
986 }
1054 }
1055+
1056+ #[test]
1057+ fn lock_is_not_stale_when_owner_pid_is_alive() {
1058+ let temp = tempfile::Builder::new()
1059+ .prefix("vb")
1060+ .tempdir_in("/tmp")
1061+ .expect("tempdir");
1062+ let lock_path = temp.path().join("vm.lock");
1063+ fs::write(&lock_path, format!("pid={}\n", std::process::id())).expect("write lock");
1064+ assert!(!is_lock_stale(&lock_path));
1065+ }
1066+
1067+ #[test]
1068+ fn lock_is_stale_when_owner_pid_is_missing() {
1069+ let temp = tempfile::Builder::new()
1070+ .prefix("vb")
1071+ .tempdir_in("/tmp")
1072+ .expect("tempdir");
1073+ let lock_path = temp.path().join("vm.lock");
1074+ fs::write(&lock_path, "pid=999999\n").expect("write lock");
1075+ assert!(is_lock_stale(&lock_path));
1076+ }
987}
1077}