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