on main help sign in
feat: allow multi vibebox to connect to the same vm.
commitca33633d28b60ebf0843293edfd61d9108063b00 authorrobcholz <84130577+robcholz@users.noreply.github.com> dateSat 7 Feb 2026, 00:22 (8 months ago) parent61d3105
10 files changed+887−62
Cargo.lock+76 Cargo.toml+2 docs/arch.mermaid+14 docs/tasks.md+16−3 src/bin/vibebox-cli.rs+63−4 src/bin/vibebox-supervisor.rs+43 src/instance.rs+131−55 src/lib.rs+1 src/vm.rs+3 src/vm_manager.rs+538
Cargo.lock+76
@@ -17,6 +17,15 @@ version = "2.0.1"
1717source = "registry+https://github.com/rust-lang/crates.io-index"
1818checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
1919
20+[[package]]
21+name = "aho-corasick"
22+version = "1.1.4"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
25+dependencies = [
26+ "memchr",
27+]
28+
2029[[package]]
2130name = "allocator-api2"
2231version = "0.2.21"
@@ -506,6 +515,15 @@ dependencies = [
506515 "hashbrown 0.15.5",
507516]
508517
518+[[package]]
519+name = "matchers"
520+version = "0.2.0"
521+source = "registry+https://github.com/rust-lang/crates.io-index"
522+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
523+dependencies = [
524+ "regex-automata",
525+]
526+
509527[[package]]
510528name = "memchr"
511529version = "2.7.6"
@@ -533,6 +551,15 @@ dependencies = [
533551 "windows-sys 0.61.2",
534552]
535553
554+[[package]]
555+name = "nu-ansi-term"
556+version = "0.50.3"
557+source = "registry+https://github.com/rust-lang/crates.io-index"
558+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
559+dependencies = [
560+ "windows-sys 0.59.0",
561+]
562+
536563[[package]]
537564name = "num-conv"
538565version = "0.2.0"
@@ -726,6 +753,23 @@ dependencies = [
726753 "bitflags",
727754]
728755
756+[[package]]
757+name = "regex-automata"
758+version = "0.4.14"
759+source = "registry+https://github.com/rust-lang/crates.io-index"
760+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
761+dependencies = [
762+ "aho-corasick",
763+ "memchr",
764+ "regex-syntax",
765+]
766+
767+[[package]]
768+name = "regex-syntax"
769+version = "0.8.9"
770+source = "registry+https://github.com/rust-lang/crates.io-index"
771+checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
772+
729773[[package]]
730774name = "rustc-demangle"
731775version = "0.1.27"
@@ -1069,9 +1113,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
10691113checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
10701114dependencies = [
10711115 "pin-project-lite",
1116+ "tracing-attributes",
10721117 "tracing-core",
10731118]
10741119
1120+[[package]]
1121+name = "tracing-attributes"
1122+version = "0.1.31"
1123+source = "registry+https://github.com/rust-lang/crates.io-index"
1124+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1125+dependencies = [
1126+ "proc-macro2",
1127+ "quote",
1128+ "syn",
1129+]
1130+
10751131[[package]]
10761132name = "tracing-core"
10771133version = "0.1.36"
@@ -1092,15 +1148,33 @@ dependencies = [
10921148 "tracing-subscriber",
10931149]
10941150
1151+[[package]]
1152+name = "tracing-log"
1153+version = "0.2.0"
1154+source = "registry+https://github.com/rust-lang/crates.io-index"
1155+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1156+dependencies = [
1157+ "log",
1158+ "once_cell",
1159+ "tracing-core",
1160+]
1161+
10951162[[package]]
10961163name = "tracing-subscriber"
10971164version = "0.3.22"
10981165source = "registry+https://github.com/rust-lang/crates.io-index"
10991166checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
11001167dependencies = [
1168+ "matchers",
1169+ "nu-ansi-term",
1170+ "once_cell",
1171+ "regex-automata",
11011172 "sharded-slab",
1173+ "smallvec",
11021174 "thread_local",
1175+ "tracing",
11031176 "tracing-core",
1177+ "tracing-log",
11041178]
11051179
11061180[[package]]
@@ -1187,6 +1261,8 @@ dependencies = [
11871261 "time",
11881262 "tokio",
11891263 "toml",
1264+ "tracing",
1265+ "tracing-subscriber",
11901266 "tui-textarea",
11911267 "unicode-width 0.2.0",
11921268 "uuid",
Cargo.toml+2
@@ -34,3 +34,5 @@ ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] }
3434tokio = { version = "1.40.0", features = ["full"] }
3535tui-textarea = { version = "0.7.0", default-features = false, features = ["ratatui"] }
3636unicode-width = "0.2"
37+tracing = "0.1"
38+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
docs/arch.mermaidnew file+14
@@ -0,0 +1,14 @@
1+flowchart TD
2+ VB[Vibebox] --> SM[Session Manager]
3+
4+ SM --> VMM1[VM Manager #1]
5+ SM --> VMM2[VM Manager #2]
6+ SM --> VMM3[VM Manager #N]
7+
8+ VMM1 --> VMI1[VM Instance #1]
9+ VMM2 --> VMI2[VM Instance #2]
10+ VMM3 --> VMIN[VM Instance #N]
11+
12+ VMI1 --> VM1[VM #1]
13+ VMI2 --> VM2[VM #2]
14+ VMIN --> VMN[VM #N]
docs/tasks.md+16−3
@@ -33,9 +33,22 @@
33332. [x] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
3434 inputfield it should not be scrollable.
3535
36−## Integration
36+## Stage 1
3737
38381. [x] Wire up the vm and tui.
39392. [x] Use ssh to connect to vm.
40−3. [ ] wire up SessionManager.
41−4. [ ] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
40+3. [x] allow multi vibebox to connect to the same vm.
41+4. [ ] use vm.lock to ensure process concurrency safety.
42+5. [ ] wire up SessionManager.
43+6. [ ] VM should be separated by a per-session VM daemon process (only accepts if to shut down vm and itself).
44+7. [ ] setup vibebox commands
45+8. [ ] setup cli commands.
46+9. [ ] fix ui overlap.
47+
48+## Publish
49+
50+1. [ ] write the docs
51+2. [ ] setup quick link.
52+3. [ ] setup website.
53+
54+[ ]
src/bin/vibebox-cli.rs+63−4
@@ -1,18 +1,49 @@
11use std::{
22 env,
3− io::{self, Write},
3+ ffi::OsString,
4+ fs,
5+ io::{self, IsTerminal, Write},
6+ path::Path,
47 sync::{Arc, Mutex},
58};
69
710use color_eyre::Result;
11+use serde::Deserialize;
12+use tracing_subscriber::EnvFilter;
813
914use vibebox::tui::{AppState, VmInfo};
10−use vibebox::{instance, tui, vm};
15+use vibebox::{instance, tui, vm, vm_manager};
16+
17+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
18+
19+#[derive(Debug, Default, Deserialize)]
20+struct ProjectConfig {
21+ auto_shutdown_ms: Option<u64>,
22+}
1123
1224fn main() -> Result<()> {
25+ init_tracing();
1326 color_eyre::install()?;
1427
28+ let raw_args: Vec<OsString> = env::args_os().collect();
29+
30+ if env::var("VIBEBOX_VM_MANAGER").as_deref() == Ok("1") {
31+ tracing::info!("starting vm manager mode");
32+ let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
33+ let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
34+ .ok()
35+ .and_then(|value| value.parse::<u64>().ok())
36+ .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
37+ tracing::info!(auto_shutdown_ms, "vm manager config");
38+ if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
39+ tracing::error!(error = %err, "vm manager exited");
40+ return Err(color_eyre::eyre::eyre!(err.to_string()));
41+ }
42+ return Ok(());
43+ }
44+
1545 let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
46+ tracing::debug!("parsed cli args");
1647 if args.version() {
1748 vm::print_version();
1849 return Ok(());
@@ -22,13 +53,16 @@ fn main() -> Result<()> {
2253 return Ok(());
2354 }
2455
56+ vm::ensure_signed();
57+
2558 let vm_info = VmInfo {
2659 version: env!("CARGO_PKG_VERSION").to_string(),
2760 max_memory_mb: args.ram_mb(),
2861 cpu_cores: args.cpu_count(),
2962 };
3063 let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
31− let app = Arc::new(Mutex::new(AppState::new(cwd, vm_info)));
64+ tracing::info!(cwd = %cwd.display(), "starting vibebox cli");
65+ let app = Arc::new(Mutex::new(AppState::new(cwd.clone(), vm_info)));
3266
3367 {
3468 let mut locked = app.lock().expect("app state poisoned");
@@ -40,8 +74,33 @@ fn main() -> Result<()> {
4074 stdout.flush()?;
4175 }
4276
43− instance::run_with_ssh(args, app.clone())
77+ let auto_shutdown_ms = load_auto_shutdown_ms(&cwd)?;
78+ tracing::info!(auto_shutdown_ms, "auto shutdown config");
79+ let manager_conn = vm_manager::ensure_manager(&raw_args, auto_shutdown_ms)
4480 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
4581
82+ instance::run_with_ssh(manager_conn).map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
83+
4684 Ok(())
4785}
86+
87+fn load_auto_shutdown_ms(project_root: &Path) -> Result<u64> {
88+ let path = project_root.join("vibebox.toml");
89+ let config = match fs::read_to_string(&path) {
90+ Ok(raw) => toml::from_str::<ProjectConfig>(&raw)?,
91+ Err(err) if err.kind() == io::ErrorKind::NotFound => ProjectConfig::default(),
92+ Err(err) => return Err(err.into()),
93+ };
94+ Ok(config.auto_shutdown_ms.unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS))
95+}
96+
97+fn init_tracing() {
98+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
99+ let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
100+ let _ = tracing_subscriber::fmt()
101+ .with_env_filter(filter)
102+ .with_target(false)
103+ .with_ansi(ansi)
104+ .with_writer(std::io::stderr)
105+ .try_init();
106+}
src/bin/vibebox-supervisor.rsnew file+43
@@ -0,0 +1,43 @@
1+use std::{
2+ env,
3+ io::{self, IsTerminal},
4+};
5+
6+use color_eyre::Result;
7+use tracing_subscriber::EnvFilter;
8+
9+use vibebox::{vm, vm_manager};
10+
11+const DEFAULT_AUTO_SHUTDOWN_MS: u64 = 3000;
12+
13+fn main() -> Result<()> {
14+ init_tracing();
15+ color_eyre::install()?;
16+
17+ tracing::info!("starting vm supervisor");
18+ let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
19+ let auto_shutdown_ms = env::var("VIBEBOX_AUTO_SHUTDOWN_MS")
20+ .ok()
21+ .and_then(|value| value.parse::<u64>().ok())
22+ .unwrap_or(DEFAULT_AUTO_SHUTDOWN_MS);
23+ tracing::info!(auto_shutdown_ms, "vm supervisor config");
24+
25+ if let Err(err) = vm_manager::run_manager(args, auto_shutdown_ms) {
26+ tracing::error!(error = %err, "vm supervisor exited");
27+ return Err(color_eyre::eyre::eyre!(err.to_string()));
28+ }
29+ tracing::info!("vm supervisor exited");
30+
31+ Ok(())
32+}
33+
34+fn init_tracing() {
35+ let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
36+ let ansi = std::io::stderr().is_terminal() && env::var("VIBEBOX_LOG_NO_COLOR").is_err();
37+ let _ = tracing_subscriber::fmt()
38+ .with_env_filter(filter)
39+ .with_target(false)
40+ .with_ansi(ansi)
41+ .with_writer(io::stderr)
42+ .try_init();
43+}
src/instance.rs+131−55
@@ -2,7 +2,7 @@ use std::{
22 env, fs,
33 io::{self, Write},
44 net::{SocketAddr, TcpStream},
5− os::unix::{fs::PermissionsExt, io::OwnedFd},
5+ os::unix::{fs::PermissionsExt, io::OwnedFd, net::UnixStream},
66 path::{Path, PathBuf},
77 process::{Command, Stdio},
88 sync::{
@@ -10,6 +10,7 @@ use std::{
1010 atomic::{AtomicBool, Ordering},
1111 },
1212 thread,
13+ time::{Duration, Instant},
1314};
1415
1516use serde::{Deserialize, Serialize};
@@ -17,13 +18,14 @@ use std::sync::mpsc::Sender;
1718use uuid::Uuid;
1819
1920use crate::{
20− session_manager::{GLOBAL_DIR_NAME, INSTANCE_DIR_NAME},
21+ session_manager::INSTANCE_DIR_NAME,
2122 tui::{self, AppState},
22− vm::{self, DirectoryShare, LoginAction, VmInput},
23+ vm::{self, LoginAction, VmInput},
2324};
2425
2526const INSTANCE_TOML: &str = "instance.toml";
2627const SSH_KEY_NAME: &str = "ssh_key";
28+#[allow(dead_code)]
2729const SERIAL_LOG_NAME: &str = "serial.log";
2830const DEFAULT_SSH_USER: &str = "vibecoder";
2931const SSH_CONNECT_RETRIES: usize = 30;
@@ -31,76 +33,49 @@ const SSH_CONNECT_DELAY_MS: u64 = 500;
3133const SSH_SETUP_SCRIPT: &str = include_str!("ssh.sh");
3234
3335#[derive(Debug, Clone, Serialize, Deserialize)]
34−struct InstanceConfig {
36+pub(crate) struct InstanceConfig {
3537 #[serde(default = "default_ssh_user")]
3638 ssh_user: String,
3739 #[serde(default)]
3840 sudo_password: String,
3941 #[serde(default)]
40− vm_ipv4: Option<String>,
42+ pub(crate) vm_ipv4: Option<String>,
4143}
4244
4345fn default_ssh_user() -> String {
4446 DEFAULT_SSH_USER.to_string()
4547}
4648
47−pub fn run_with_ssh(
48− args: vm::CliArgs,
49− app: Arc<Mutex<AppState>>,
50−) -> Result<(), Box<dyn std::error::Error>> {
49+pub fn run_with_ssh(manager_conn: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
5150 let project_root = env::current_dir()?;
52− let project_name = project_root
53− .file_name()
54− .ok_or("Project directory has no name")?
55− .to_string_lossy()
56− .into_owned();
51+ tracing::info!(root = %project_root.display(), "starting ssh session");
5752 let instance_dir = ensure_instance_dir(&project_root)?;
53+ tracing::debug!(instance_dir = %instance_dir.display(), "instance dir ready");
5854 let (ssh_key, _ssh_pub) = ensure_ssh_keypair(&instance_dir)?;
5955
60− let mut config = load_or_create_instance_config(&instance_dir)?;
61− // Clear cached IP to avoid reusing a stale address on startup.
62− if config.vm_ipv4.is_some() {
63− config.vm_ipv4 = None;
64− write_instance_config(&instance_dir.join(INSTANCE_TOML), &config)?;
65− }
66− let config = Arc::new(Mutex::new(config));
67−
68− let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
69−
70− let extra_shares = vec![DirectoryShare::new(
71− instance_dir.clone(),
72− ssh_guest_dir.clone().into(),
73− true,
74− )?];
75−
76− let extra_login_actions =
77− build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), SSH_KEY_NAME);
78−
79− vm::run_with_args_and_extras(
80− args,
81− |output_monitor, vm_output_fd, vm_input_fd| {
82− spawn_ssh_io(
83− app.clone(),
84− config.clone(),
85− instance_dir.clone(),
86− ssh_key.clone(),
87− output_monitor,
88− vm_output_fd,
89− vm_input_fd,
90− )
91− },
92− extra_login_actions,
93− extra_shares,
94− )
56+ let config = load_or_create_instance_config(&instance_dir)?;
57+ let ssh_user = config.ssh_user.clone();
58+ tracing::debug!(ssh_user = %ssh_user, "loaded instance config");
59+
60+ let _manager_conn = manager_conn;
61+ tracing::debug!("waiting for vm ipv4");
62+ wait_for_vm_ipv4(&instance_dir, Duration::from_secs(120))?;
63+
64+ let ip = load_or_create_instance_config(&instance_dir)?
65+ .vm_ipv4
66+ .ok_or("VM IPv4 not available")?;
67+ tracing::info!(ip = %ip, "vm ipv4 ready");
68+
69+ run_ssh_session(ssh_key, ssh_user, ip)
9570}
9671
97−fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
72+pub(crate) fn ensure_instance_dir(project_root: &Path) -> Result<PathBuf, io::Error> {
9873 let instance_dir = project_root.join(INSTANCE_DIR_NAME);
9974 fs::create_dir_all(&instance_dir)?;
10075 Ok(instance_dir)
10176}
10277
103−fn ensure_ssh_keypair(
78+pub(crate) fn ensure_ssh_keypair(
10479 instance_dir: &Path,
10580) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
10681 let private_key = instance_dir.join(SSH_KEY_NAME);
@@ -143,7 +118,7 @@ fn ensure_ssh_keypair(
143118 Ok((private_key, public_key))
144119}
145120
146−fn load_or_create_instance_config(
121+pub(crate) fn load_or_create_instance_config(
147122 instance_dir: &Path,
148123) -> Result<InstanceConfig, Box<dyn std::error::Error>> {
149124 let config_path = instance_dir.join(INSTANCE_TOML);
@@ -176,7 +151,7 @@ fn load_or_create_instance_config(
176151 Ok(config)
177152}
178153
179−fn write_instance_config(
154+pub(crate) fn write_instance_config(
180155 path: &Path,
181156 config: &InstanceConfig,
182157) -> Result<(), Box<dyn std::error::Error>> {
@@ -190,7 +165,7 @@ fn generate_password() -> String {
190165 Uuid::now_v7().simple().to_string()
191166}
192167
193−fn extract_ipv4(line: &str) -> Option<String> {
168+pub(crate) fn extract_ipv4(line: &str) -> Option<String> {
194169 let mut current = String::new();
195170 let mut best: Option<String> = None;
196171
@@ -209,6 +184,106 @@ fn extract_ipv4(line: &str) -> Option<String> {
209184 best
210185}
211186
187+fn wait_for_vm_ipv4(
188+ instance_dir: &Path,
189+ timeout: Duration,
190+) -> Result<(), Box<dyn std::error::Error>> {
191+ let start = Instant::now();
192+ loop {
193+ let config = load_or_create_instance_config(instance_dir)?;
194+ if config.vm_ipv4.is_some() {
195+ return Ok(());
196+ }
197+ if start.elapsed() > timeout {
198+ return Err("Timed out waiting for VM IPv4".into());
199+ }
200+ if start.elapsed().as_secs() % 5 == 0 {
201+ tracing::debug!("still waiting for vm ipv4");
202+ }
203+ thread::sleep(Duration::from_millis(200));
204+ }
205+}
206+
207+fn run_ssh_session(
208+ ssh_key: PathBuf,
209+ ssh_user: String,
210+ ip: String,
211+) -> Result<(), Box<dyn std::error::Error>> {
212+ let mut attempts = 0usize;
213+ loop {
214+ attempts += 1;
215+ if !ssh_port_open(&ip) {
216+ tracing::debug!(attempts, "ssh port not open yet");
217+ eprintln!(
218+ "[vibebox] waiting for ssh port on {ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
219+ );
220+ if attempts >= SSH_CONNECT_RETRIES {
221+ return Err(
222+ format!("ssh port not ready after {SSH_CONNECT_RETRIES} attempts").into(),
223+ );
224+ }
225+ thread::sleep(Duration::from_millis(SSH_CONNECT_DELAY_MS));
226+ continue;
227+ }
228+
229+ eprintln!(
230+ "[vibebox] starting ssh to {ssh_user}@{ip} (attempt {attempts}/{SSH_CONNECT_RETRIES})"
231+ );
232+ tracing::info!(attempts, user = %ssh_user, ip = %ip, "starting ssh");
233+ let status = Command::new("ssh")
234+ .args([
235+ "-i",
236+ ssh_key.to_str().unwrap_or(".vibebox/ssh_key"),
237+ "-o",
238+ "IdentitiesOnly=yes",
239+ "-o",
240+ "StrictHostKeyChecking=no",
241+ "-o",
242+ "UserKnownHostsFile=/dev/null",
243+ "-o",
244+ "GlobalKnownHostsFile=/dev/null",
245+ "-o",
246+ "PasswordAuthentication=no",
247+ "-o",
248+ "BatchMode=yes",
249+ "-o",
250+ "LogLevel=ERROR",
251+ "-o",
252+ "ConnectTimeout=5",
253+ ])
254+ .env_remove("LC_CTYPE")
255+ .env_remove("LC_ALL")
256+ .env_remove("LANG")
257+ .arg(format!("{ssh_user}@{ip}"))
258+ .stdin(Stdio::inherit())
259+ .stdout(Stdio::inherit())
260+ .stderr(Stdio::inherit())
261+ .status();
262+
263+ match status {
264+ Ok(status) if status.success() => {
265+ eprintln!("[vibebox] ssh exited with status: {status}");
266+ break;
267+ }
268+ Ok(status) if status.code() == Some(255) => {
269+ eprintln!("[vibebox] ssh connection failed: {status}");
270+ if attempts >= SSH_CONNECT_RETRIES {
271+ return Err(format!("ssh failed after {SSH_CONNECT_RETRIES} attempts").into());
272+ }
273+ thread::sleep(Duration::from_millis(500));
274+ }
275+ Ok(status) => {
276+ return Err(format!("ssh exited with status: {status}").into());
277+ }
278+ Err(err) => {
279+ return Err(format!("failed to start ssh: {err}").into());
280+ }
281+ }
282+ }
283+
284+ Ok(())
285+}
286+
212287fn is_ipv4_candidate(candidate: &str) -> bool {
213288 let parts: Vec<&str> = candidate.split('.').collect();
214289 if parts.len() != 4 {
@@ -233,7 +308,7 @@ fn ssh_port_open(ip: &str) -> bool {
233308 TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).is_ok()
234309}
235310
236−fn build_ssh_login_actions(
311+pub(crate) fn build_ssh_login_actions(
237312 config: &Arc<Mutex<InstanceConfig>>,
238313 project_name: &str,
239314 guest_dir: &str,
@@ -257,6 +332,7 @@ fn build_ssh_login_actions(
257332 vec![LoginAction::Send(setup)]
258333}
259334
335+#[allow(dead_code)]
260336fn spawn_ssh_io(
261337 app: Arc<Mutex<AppState>>,
262338 config: Arc<Mutex<InstanceConfig>>,
src/lib.rs+1
@@ -2,5 +2,6 @@ pub mod instance;
22pub mod session_manager;
33pub mod tui;
44pub mod vm;
5+pub mod vm_manager;
56
67pub use session_manager::{SessionError, SessionManager, SessionRecord};
src/vm.rs+3
@@ -1358,6 +1358,9 @@ impl Drop for RawModeGuard {
13581358
13591359// Ensure the running binary has com.apple.security.virtualization entitlements by checking and, if not, signing and relaunching.
13601360pub fn ensure_signed() {
1361+ if std::env::var("VIBEBOX_SKIP_CODESIGN").as_deref() == Ok("1") {
1362+ return;
1363+ }
13611364 let exe = std::env::current_exe().expect("failed to get current exe path");
13621365 let exe_str = exe.to_str().expect("exe path not valid utf-8");
13631366
src/vm_manager.rsnew file+538
@@ -0,0 +1,538 @@
1+use std::{
2+ env, fs,
3+ io::{Read, Write},
4+ os::unix::{
5+ fs::PermissionsExt,
6+ io::AsRawFd,
7+ net::{UnixListener, UnixStream},
8+ process::CommandExt,
9+ },
10+ path::{Path, PathBuf},
11+ process::{Command, Stdio},
12+ sync::{Arc, Mutex, mpsc},
13+ thread,
14+ time::{Duration, Instant},
15+};
16+
17+use crate::{
18+ instance::{
19+ InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
20+ extract_ipv4, load_or_create_instance_config, write_instance_config,
21+ },
22+ session_manager::GLOBAL_DIR_NAME,
23+ vm::{self, DirectoryShare, LoginAction, VmInput},
24+};
25+
26+const VM_MANAGER_SOCKET_NAME: &str = "vm.sock";
27+
28+pub fn ensure_manager(
29+ raw_args: &[std::ffi::OsString],
30+ auto_shutdown_ms: u64,
31+) -> Result<UnixStream, Box<dyn std::error::Error>> {
32+ let project_root = env::current_dir()?;
33+ tracing::debug!(root = %project_root.display(), "ensure vm manager");
34+ let instance_dir = ensure_instance_dir(&project_root)?;
35+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
36+
37+ if let Ok(stream) = UnixStream::connect(&socket_path) {
38+ send_client_pid(&stream);
39+ tracing::info!(path = %socket_path.display(), "connected to existing vm manager");
40+ return Ok(stream);
41+ }
42+
43+ tracing::info!(path = %socket_path.display(), "spawning vm manager");
44+ spawn_manager_process(raw_args, auto_shutdown_ms, &instance_dir)?;
45+
46+ let start = Instant::now();
47+ let timeout = Duration::from_secs(10);
48+ loop {
49+ match UnixStream::connect(&socket_path) {
50+ Ok(stream) => {
51+ send_client_pid(&stream);
52+ tracing::info!(path = %socket_path.display(), "connected to vm manager");
53+ return Ok(stream);
54+ }
55+ Err(err) => {
56+ tracing::debug!(error = %err, "waiting for vm manager socket");
57+ if start.elapsed() > timeout {
58+ return Err(format!(
59+ "Timed out waiting for vm manager socket: {} ({})",
60+ socket_path.display(),
61+ err
62+ )
63+ .into());
64+ }
65+ thread::sleep(Duration::from_millis(100));
66+ }
67+ }
68+ }
69+}
70+
71+pub fn run_manager(
72+ args: vm::CliArgs,
73+ auto_shutdown_ms: u64,
74+) -> Result<(), Box<dyn std::error::Error>> {
75+ let project_root = env::current_dir()?;
76+ tracing::info!(root = %project_root.display(), "vm manager starting");
77+ run_manager_with(
78+ &project_root,
79+ args,
80+ auto_shutdown_ms,
81+ &RealVmExecutor,
82+ ManagerOptions {
83+ ensure_signed: true,
84+ detach: true,
85+ prepare_vm: true,
86+ },
87+ )
88+}
89+
90+fn spawn_manager_process(
91+ raw_args: &[std::ffi::OsString],
92+ auto_shutdown_ms: u64,
93+ instance_dir: &Path,
94+) -> Result<(), Box<dyn std::error::Error>> {
95+ let exe = env::current_exe()?;
96+ let mut supervisor_exe = exe.clone();
97+ supervisor_exe.set_file_name("vibebox-supervisor");
98+ let use_supervisor = supervisor_exe.exists();
99+ let mut cmd = if use_supervisor {
100+ Command::new(supervisor_exe)
101+ } else {
102+ let mut cmd = Command::new(exe);
103+ cmd.arg0("vibebox-supervisor");
104+ cmd
105+ };
106+ if raw_args.len() > 1 {
107+ cmd.args(&raw_args[1..]);
108+ }
109+ if !use_supervisor {
110+ cmd.env("VIBEBOX_VM_MANAGER", "1");
111+ }
112+ cmd.env("VIBEBOX_LOG_NO_COLOR", "1");
113+ cmd.env("VIBEBOX_AUTO_SHUTDOWN_MS", auto_shutdown_ms.to_string());
114+ tracing::info!(auto_shutdown_ms, "vm manager process spawn requested");
115+ let log_path = instance_dir.join("vm_manager.log");
116+ let log_file = fs::OpenOptions::new()
117+ .create(true)
118+ .append(true)
119+ .open(&log_path)
120+ .ok();
121+ if let Some(file) = log_file {
122+ let stderr = Stdio::from(file);
123+ cmd.stdin(Stdio::null())
124+ .stdout(Stdio::null())
125+ .stderr(stderr);
126+ } else {
127+ cmd.stdin(Stdio::null())
128+ .stdout(Stdio::null())
129+ .stderr(Stdio::null());
130+ }
131+ let _child = cmd.spawn()?;
132+ Ok(())
133+}
134+
135+fn detach_from_terminal() {
136+ unsafe {
137+ libc::setsid();
138+ }
139+
140+ if let Ok(devnull) = fs::OpenOptions::new()
141+ .read(true)
142+ .write(true)
143+ .open("/dev/null")
144+ {
145+ let _ = unsafe { libc::dup2(devnull.as_raw_fd(), libc::STDIN_FILENO) };
146+ let _ = unsafe { libc::dup2(devnull.as_raw_fd(), libc::STDOUT_FILENO) };
147+ }
148+}
149+
150+fn wait_for_disconnect(mut stream: UnixStream) {
151+ let mut buf = [0u8; 64];
152+ loop {
153+ match stream.read(&mut buf) {
154+ Ok(0) => break,
155+ Ok(_) => continue,
156+ Err(_) => break,
157+ }
158+ }
159+}
160+
161+fn send_client_pid(stream: &UnixStream) {
162+ let pid = std::process::id();
163+ let payload = format!("pid={pid}\n");
164+ if let Ok(mut stream) = stream.try_clone() {
165+ let _ = stream.write_all(payload.as_bytes());
166+ let _ = stream.flush();
167+ }
168+}
169+
170+fn read_client_pid(stream: &UnixStream) -> Option<u32> {
171+ let mut stream = stream.try_clone().ok()?;
172+ let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
173+ let mut buf = [0u8; 64];
174+ let mut len = 0usize;
175+ loop {
176+ match stream.read(&mut buf[len..]) {
177+ Ok(0) => break,
178+ Ok(n) => {
179+ len += n;
180+ if buf[..len].iter().any(|b| *b == b'\n') || len == buf.len() {
181+ break;
182+ }
183+ }
184+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
185+ Err(err) if err.kind() == std::io::ErrorKind::TimedOut => break,
186+ Err(_) => break,
187+ }
188+ }
189+ let _ = stream.set_read_timeout(None);
190+ if len == 0 {
191+ return None;
192+ }
193+ let line = String::from_utf8_lossy(&buf[..len]);
194+ let trimmed = line.trim();
195+ if let Some(value) = trimmed.strip_prefix("pid=") {
196+ value.parse::<u32>().ok()
197+ } else {
198+ None
199+ }
200+}
201+
202+fn spawn_manager_io(
203+ config: Arc<Mutex<InstanceConfig>>,
204+ instance_dir: PathBuf,
205+ output_monitor: Arc<vm::OutputMonitor>,
206+ vm_output_fd: std::os::unix::io::OwnedFd,
207+ vm_input_fd: std::os::unix::io::OwnedFd,
208+) -> vm::IoContext {
209+ let log_path = instance_dir.join("serial.log");
210+ let log_file = fs::OpenOptions::new()
211+ .create(true)
212+ .append(true)
213+ .open(&log_path)
214+ .ok()
215+ .map(|file| Arc::new(Mutex::new(file)));
216+
217+ let instance_path = instance_dir.join("instance.toml");
218+ let config_for_output = config.clone();
219+ let log_for_output = log_file.clone();
220+ let mut line_buf = String::new();
221+
222+ let on_output = move |bytes: &[u8]| {
223+ if let Some(log) = &log_for_output {
224+ if let Ok(mut file) = log.lock() {
225+ let _ = file.write_all(bytes);
226+ }
227+ }
228+
229+ let text = String::from_utf8_lossy(bytes);
230+ line_buf.push_str(&text);
231+
232+ while let Some(pos) = line_buf.find('\n') {
233+ let mut line = line_buf[..pos].to_string();
234+ if line.ends_with('\r') {
235+ line.pop();
236+ }
237+ line_buf.drain(..=pos);
238+
239+ let cleaned = line.trim_start_matches(|c: char| c == '\r' || c == ' ');
240+ if let Some(pos) = cleaned.find("VIBEBOX_IPV4=") {
241+ let ip_raw = &cleaned[(pos + "VIBEBOX_IPV4=".len())..];
242+ let ip = extract_ipv4(ip_raw).unwrap_or_default();
243+ if !ip.is_empty() {
244+ if let Ok(mut cfg) = config_for_output.lock() {
245+ if cfg.vm_ipv4.as_deref() != Some(ip.as_str()) {
246+ cfg.vm_ipv4 = Some(ip.clone());
247+ let _ = write_instance_config(&instance_path, &cfg);
248+ }
249+ }
250+ }
251+ }
252+ }
253+ };
254+
255+ vm::spawn_vm_io_with_hooks(
256+ output_monitor,
257+ vm_output_fd,
258+ vm_input_fd,
259+ vm::IoControl::new(),
260+ |_| false,
261+ on_output,
262+ )
263+}
264+
265+enum ManagerEvent {
266+ Inc(Option<u32>),
267+ Dec(Option<u32>),
268+ VmExited(Option<String>),
269+}
270+
271+#[cfg(test)]
272+mod tests {
273+ use super::*;
274+ use std::{sync::mpsc, time::Duration};
275+
276+ #[test]
277+ fn manager_powers_off_after_grace_when_no_refs() {
278+ let _temp = tempfile::Builder::new()
279+ .prefix("vb")
280+ .tempdir_in("/tmp")
281+ .expect("tempdir");
282+
283+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
284+ let (vm_tx, vm_rx) = mpsc::channel::<VmInput>();
285+ let vm_input_tx = Arc::new(Mutex::new(Some(vm_tx)));
286+
287+ let manager_thread = thread::spawn(move || {
288+ manager_event_loop(event_rx, vm_input_tx, 50).expect("event loop");
289+ });
290+
291+ event_tx.send(ManagerEvent::Inc(None)).unwrap();
292+ assert!(vm_rx.recv_timeout(Duration::from_millis(100)).is_err());
293+
294+ event_tx.send(ManagerEvent::Dec(None)).unwrap();
295+ let msg = vm_rx
296+ .recv_timeout(Duration::from_secs(2))
297+ .expect("poweroff");
298+ match msg {
299+ VmInput::Bytes(data) => {
300+ assert_eq!(data, b"systemctl poweroff\n");
301+ }
302+ _ => panic!("unexpected vm input"),
303+ }
304+ let _ = event_tx.send(ManagerEvent::VmExited(None));
305+ let _ = manager_thread.join();
306+ }
307+}
308+
309+struct ManagerOptions {
310+ ensure_signed: bool,
311+ detach: bool,
312+ prepare_vm: bool,
313+}
314+
315+trait VmExecutor {
316+ fn run_vm(
317+ &self,
318+ args: vm::CliArgs,
319+ extra_login_actions: Vec<LoginAction>,
320+ extra_shares: Vec<DirectoryShare>,
321+ config: Arc<Mutex<InstanceConfig>>,
322+ instance_dir: PathBuf,
323+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
324+ ) -> Result<(), Box<dyn std::error::Error>>;
325+}
326+
327+struct RealVmExecutor;
328+
329+impl VmExecutor for RealVmExecutor {
330+ fn run_vm(
331+ &self,
332+ args: vm::CliArgs,
333+ extra_login_actions: Vec<LoginAction>,
334+ extra_shares: Vec<DirectoryShare>,
335+ config: Arc<Mutex<InstanceConfig>>,
336+ instance_dir: PathBuf,
337+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
338+ ) -> Result<(), Box<dyn std::error::Error>> {
339+ let result = vm::run_with_args_and_extras(
340+ args,
341+ |output_monitor, vm_output_fd, vm_input_fd| {
342+ let io_ctx = spawn_manager_io(
343+ config.clone(),
344+ instance_dir.clone(),
345+ output_monitor,
346+ vm_output_fd,
347+ vm_input_fd,
348+ );
349+ *vm_input_tx.lock().unwrap() = Some(io_ctx.input_tx.clone());
350+ io_ctx
351+ },
352+ extra_login_actions,
353+ extra_shares,
354+ );
355+ result
356+ }
357+}
358+
359+fn run_manager_with(
360+ project_root: &Path,
361+ args: vm::CliArgs,
362+ auto_shutdown_ms: u64,
363+ executor: &dyn VmExecutor,
364+ options: ManagerOptions,
365+) -> Result<(), Box<dyn std::error::Error>> {
366+ if options.ensure_signed {
367+ let _had_skip = env::var("VIBEBOX_SKIP_CODESIGN").ok();
368+ unsafe {
369+ env::remove_var("VIBEBOX_SKIP_CODESIGN");
370+ }
371+ vm::ensure_signed();
372+ unsafe {
373+ env::set_var("VIBEBOX_SKIP_CODESIGN", "1");
374+ }
375+ }
376+ if options.detach {
377+ detach_from_terminal();
378+ }
379+
380+ let project_name = project_root
381+ .file_name()
382+ .ok_or("Project directory has no name")?
383+ .to_string_lossy()
384+ .into_owned();
385+ let instance_dir = ensure_instance_dir(project_root)?;
386+ if options.prepare_vm {
387+ let _ = ensure_ssh_keypair(&instance_dir)?;
388+ }
389+
390+ let mut config = load_or_create_instance_config(&instance_dir)?;
391+ if config.vm_ipv4.is_some() {
392+ config.vm_ipv4 = None;
393+ write_instance_config(&instance_dir.join("instance.toml"), &config)?;
394+ }
395+ let config = Arc::new(Mutex::new(config));
396+
397+ let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
398+ let extra_shares = vec![DirectoryShare::new(
399+ instance_dir.clone(),
400+ ssh_guest_dir.clone().into(),
401+ true,
402+ )?];
403+ let extra_login_actions =
404+ build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
405+
406+ let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
407+ if let Ok(stream) = UnixStream::connect(&socket_path) {
408+ drop(stream);
409+ return Ok(());
410+ }
411+ if socket_path.exists() {
412+ let _ = fs::remove_file(&socket_path);
413+ }
414+
415+ let listener = UnixListener::bind(&socket_path)?;
416+ let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600));
417+ tracing::info!(path = %socket_path.display(), "vm manager socket bound");
418+
419+ let (event_tx, event_rx) = mpsc::channel::<ManagerEvent>();
420+ let event_tx_accept = event_tx.clone();
421+ thread::spawn(move || {
422+ for stream in listener.incoming() {
423+ match stream {
424+ Ok(stream) => {
425+ let event_tx_conn = event_tx_accept.clone();
426+ thread::spawn(move || {
427+ let pid = read_client_pid(&stream);
428+ let _ = event_tx_conn.send(ManagerEvent::Inc(pid));
429+ wait_for_disconnect(stream);
430+ let _ = event_tx_conn.send(ManagerEvent::Dec(pid));
431+ });
432+ }
433+ Err(_) => break,
434+ }
435+ }
436+ });
437+
438+ let vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>> = Arc::new(Mutex::new(None));
439+ let vm_input_for_loop = vm_input_tx.clone();
440+ let event_loop_handle =
441+ thread::spawn(move || manager_event_loop(event_rx, vm_input_for_loop, auto_shutdown_ms));
442+
443+ tracing::info!("vm manager launching vm");
444+ let vm_result = executor.run_vm(
445+ args,
446+ extra_login_actions,
447+ extra_shares,
448+ config.clone(),
449+ instance_dir.clone(),
450+ vm_input_tx.clone(),
451+ );
452+ tracing::info!("vm manager vm run completed");
453+ let vm_err = vm_result.err().map(|e| e.to_string());
454+ let _ = event_tx.send(ManagerEvent::VmExited(vm_err.clone()));
455+ let event_loop_result: Result<(), String> = event_loop_handle
456+ .join()
457+ .unwrap_or_else(|_| Err("vm manager event loop panicked".into()))
458+ .map_err(|err| err.to_string());
459+ let _ = fs::remove_file(&socket_path);
460+ if let Err(err) = &event_loop_result {
461+ tracing::error!(error = %err, "vm manager exiting due to event loop error");
462+ return Err(err.to_string().into());
463+ }
464+ if let Some(err) = vm_err {
465+ tracing::error!(error = %err, "vm manager exiting due to vm error");
466+ return Err(err.into());
467+ }
468+ tracing::info!("vm manager exiting");
469+ Ok(event_loop_result?)
470+}
471+
472+fn manager_event_loop(
473+ event_rx: mpsc::Receiver<ManagerEvent>,
474+ vm_input_tx: Arc<Mutex<Option<mpsc::Sender<VmInput>>>>,
475+ auto_shutdown_ms: u64,
476+) -> Result<(), String> {
477+ let mut ref_count: usize = 0;
478+ let mut shutdown_deadline: Option<Instant> = None;
479+ let mut shutdown_sent = false;
480+ let grace = Duration::from_millis(auto_shutdown_ms.max(1));
481+
482+ loop {
483+ let timeout = match shutdown_deadline {
484+ Some(deadline) => deadline.saturating_duration_since(Instant::now()),
485+ None => Duration::from_secs(1),
486+ };
487+
488+ match event_rx.recv_timeout(timeout) {
489+ Ok(ManagerEvent::Inc(pid)) => {
490+ ref_count = ref_count.saturating_add(1);
491+ tracing::info!(
492+ ref_count,
493+ pid = pid.unwrap_or(0),
494+ pid_known = pid.is_some(),
495+ "vm manager refcount increment"
496+ );
497+ shutdown_deadline = None;
498+ shutdown_sent = false;
499+ }
500+ Ok(ManagerEvent::Dec(pid)) => {
501+ if ref_count > 0 {
502+ ref_count -= 1;
503+ }
504+ tracing::info!(
505+ ref_count,
506+ pid = pid.unwrap_or(0),
507+ pid_known = pid.is_some(),
508+ "vm manager refcount decrement"
509+ );
510+ if ref_count == 0 {
511+ shutdown_deadline = Some(Instant::now() + grace);
512+ tracing::info!(grace_ms = auto_shutdown_ms, "shutdown scheduled");
513+ }
514+ }
515+ Ok(ManagerEvent::VmExited(err)) => {
516+ if let Some(err) = err {
517+ tracing::error!(error = %err, "vm exited with error");
518+ }
519+ break;
520+ }
521+ Err(mpsc::RecvTimeoutError::Timeout) => {
522+ if let Some(deadline) = shutdown_deadline {
523+ if Instant::now() >= deadline && !shutdown_sent {
524+ if let Some(tx) = vm_input_tx.lock().unwrap().clone() {
525+ let _ = tx.send(VmInput::Bytes(b"systemctl poweroff\n".to_vec()));
526+ }
527+ tracing::info!("shutdown command sent");
528+ shutdown_sent = true;
529+ shutdown_deadline = None;
530+ }
531+ }
532+ }
533+ Err(mpsc::RecvTimeoutError::Disconnected) => break,
534+ }
535+ }
536+
537+ Ok(())
538+}