on main help sign in
feat: wired up vm and tui
52bcd88robcholz
docs/implementations.md+11−1
@@ -88,4 +88,14 @@ They are also stored per project, in `vibebox.toml`
88- use `:explain` to display:
88- use `:explain` to display:
89 - mounts: host_path → guest_path, ro/rw
89 - mounts: host_path → guest_path, ro/rw
90 - network: mode (allowlist/blocklist) and entries
90 - network: mode (allowlist/blocklist) and entries
91− - storage: paths to vibebox.toml and .vibebox/ (relative from the project_dir)
91+ - storage: paths to vibebox.toml and .vibebox/ (relative from the project_dir)
92+
93+## Connection
94+
95+### SSH
96+
97+- In Project cache, generate and store ssh pair
98+- In provisioning, install and enable openssh-server in VM
99+- Mount ssh pair to VM when starting up
100+- get ipv4 address of VM, store it to project cache
101+- and connect to VM via ssh with ip and ssh key
docs/tasks.md+6−3
@@ -29,10 +29,13 @@
29
29
30## TUI
30## TUI
31
31
32−1. [ ] Fix the terminal component height issue.
32+1. [x] Fix the terminal component height issue.
33−2. [ ] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
33+2. [x] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
34 inputfield it should not be scrollable.
34 inputfield it should not be scrollable.
35
35
36## Integration
36## Integration
37
37
38+1. [x] Wire up the vm and tui.
39+2. [ ] Use ssh to connect to vm.
38−1. [ ] Integrate VM and SessionManager together.
40+3. [ ] wire up SessionManager.
41+4. [ ] VM should be separated by per-session VM daemon process (only accepts if to shutdown vm and itself).
src/bin/vibebox-cli.rsnew file+49
@@ -0,0 +1,49 @@
1+use std::{
2+ env,
3+ io::{self, Write},
4+ sync::{Arc, Mutex},
5+};
6+
7+use color_eyre::Result;
8+
9+use vibebox::tui::{AppState, VmInfo};
10+use vibebox::{tui, vm};
11+
12+fn main() -> Result<()> {
13+ color_eyre::install()?;
14+
15+ let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
16+ if args.version() {
17+ vm::print_version();
18+ return Ok(());
19+ }
20+ if args.help() {
21+ vm::print_help();
22+ return Ok(());
23+ }
24+
25+ let vm_info = VmInfo {
26+ version: env!("CARGO_PKG_VERSION").to_string(),
27+ max_memory_mb: args.ram_mb(),
28+ cpu_cores: args.cpu_count(),
29+ };
30+ 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)));
32+
33+ {
34+ let mut locked = app.lock().expect("app state poisoned");
35+ tui::render_tui_once(&mut locked)?;
36+ }
37+ {
38+ let mut stdout = io::stdout().lock();
39+ writeln!(stdout)?;
40+ stdout.flush()?;
41+ }
42+
43+ vm::run_with_args(args, |output_monitor, vm_output_fd, vm_input_fd| {
44+ tui::passthrough_vm_io(app.clone(), output_monitor, vm_output_fd, vm_input_fd)
45+ })
46+ .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
47+
48+ Ok(())
49+}
src/bin/vibebox-tui.rsdeleted−247
@@ -1,247 +0,0 @@
1−use std::{
2− env,
3− ffi::OsString,
4− io::{self, Read, Write},
5− path::PathBuf,
6−};
7−
8−use color_eyre::Result;
9−use lexopt::prelude::*;
10−
11−#[path = "../tui.rs"]
12−mod tui;
13−
14−use tui::{AppState, VmInfo};
15−
16−#[derive(Debug, Clone, PartialEq, Eq)]
17−struct TuiConfig {
18− cwd: PathBuf,
19− vm_version: String,
20− max_memory_mb: u64,
21− cpu_cores: usize,
22−}
23−
24−#[derive(Debug, Clone, PartialEq, Eq)]
25−enum TuiCommand {
26− Run(TuiConfig),
27− Help,
28− Version,
29−}
30−
31−#[derive(Debug, thiserror::Error)]
32−enum CliError {
33− #[error("{0}")]
34− Message(String),
35− #[error(transparent)]
36− Lexopt(#[from] lexopt::Error),
37− #[error(transparent)]
38− Io(#[from] std::io::Error),
39−}
40−
41−fn main() -> Result<()> {
42− color_eyre::install()?;
43−
44− let command = parse_args(env::args_os())?;
45− match command {
46− TuiCommand::Help => {
47− print_help();
48− }
49− TuiCommand::Version => {
50− println!("vibebox-tui {}", env!("CARGO_PKG_VERSION"));
51− }
52− TuiCommand::Run(config) => {
53− let vm_info = VmInfo {
54− version: config.vm_version,
55− max_memory_mb: config.max_memory_mb,
56− cpu_cores: config.cpu_cores,
57− };
58− let mut app = AppState::new(config.cwd, vm_info);
59− tui::render_tui_once(&mut app)?;
60− {
61− let mut stdout = io::stdout().lock();
62− writeln!(stdout)?;
63− stdout.flush()?;
64− }
65− passthrough_stdio(&mut app)?;
66− }
67− }
68−
69− Ok(())
70−}
71−
72−fn passthrough_stdio(app: &mut AppState) -> io::Result<()> {
73− let mut stdin = io::stdin().lock();
74− let mut buf = [0u8; 8192];
75− let mut line_buf: Vec<u8> = Vec::new();
76− loop {
77− let n = stdin.read(&mut buf)?;
78− if n == 0 {
79− break;
80− }
81− for &b in &buf[..n] {
82− line_buf.push(b);
83− if b == b'\n' {
84− let line = String::from_utf8_lossy(&line_buf);
85− let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
86− if trimmed == ":help" {
87− let _ = tui::render_commands_component(app);
88− }
89− line_buf.clear();
90− }
91− }
92− }
93− Ok(())
94−}
95−
96−fn print_help() {
97− println!(
98− "vibebox-tui\n\nUsage:\n vibebox-tui [options]\n\nOptions:\n --help, -h Show this help\n --version Show version\n --cwd <path> Working directory for the session header\n --vm-version <ver> VM version string for the header\n --max-memory <mb> Max memory in MB (default 2048)\n --cpu-cores <count> CPU core count (default 2)\n"
99− );
100−}
101−
102−fn parse_args<I>(args: I) -> Result<TuiCommand, CliError>
103−where
104− I: IntoIterator<Item = OsString>,
105−{
106− fn os_to_string(value: OsString, flag: &str) -> Result<String, CliError> {
107− value
108− .into_string()
109− .map_err(|_| CliError::Message(format!("{flag} expects valid UTF-8")))
110− }
111−
112− let mut parser = lexopt::Parser::from_iter(args);
113− let mut cwd: Option<PathBuf> = None;
114− let mut vm_version = env!("CARGO_PKG_VERSION").to_string();
115− let mut max_memory_mb: u64 = 2048;
116− let mut cpu_cores: usize = 2;
117−
118− while let Some(arg) = parser.next()? {
119− match arg {
120− Long("help") | Short('h') => return Ok(TuiCommand::Help),
121− Long("version") => return Ok(TuiCommand::Version),
122− Long("cwd") => {
123− let value = os_to_string(parser.value()?, "--cwd")?;
124− cwd = Some(PathBuf::from(value));
125− }
126− Long("vm-version") => {
127− vm_version = os_to_string(parser.value()?, "--vm-version")?;
128− }
129− Long("max-memory") => {
130− let value: u64 = os_to_string(parser.value()?, "--max-memory")?
131− .parse()
132− .map_err(|_| {
133− CliError::Message("--max-memory expects an integer".to_string())
134− })?;
135− if value == 0 {
136− return Err(CliError::Message("--max-memory must be >= 1".to_string()));
137− }
138− max_memory_mb = value;
139− }
140− Long("cpu-cores") => {
141− let value: usize = os_to_string(parser.value()?, "--cpu-cores")?
142− .parse()
143− .map_err(|_| CliError::Message("--cpu-cores expects an integer".to_string()))?;
144− if value == 0 {
145− return Err(CliError::Message("--cpu-cores must be >= 1".to_string()));
146− }
147− cpu_cores = value;
148− }
149− Value(value) => {
150− return Err(CliError::Message(format!(
151− "unexpected argument: {}",
152− value.to_string_lossy()
153− )));
154− }
155− _ => return Err(CliError::Message(arg.unexpected().to_string())),
156− }
157− }
158−
159− let cwd = match cwd {
160− Some(dir) => dir,
161− None => env::current_dir()?,
162− };
163−
164− Ok(TuiCommand::Run(TuiConfig {
165− cwd,
166− vm_version,
167− max_memory_mb,
168− cpu_cores,
169− }))
170−}
171−
172−#[cfg(test)]
173−mod tests {
174− use super::*;
175−
176− fn parse_from(args: &[&str]) -> Result<TuiCommand, CliError> {
177− let mut argv = vec![OsString::from("vibebox-tui")];
178− argv.extend(args.iter().map(OsString::from));
179− parse_args(argv)
180− }
181−
182− #[test]
183− fn parse_help_short_circuit() {
184− let command = parse_from(&["--help"]).unwrap();
185− assert!(matches!(command, TuiCommand::Help));
186− }
187−
188− #[test]
189− fn parse_version_short_circuit() {
190− let command = parse_from(&["--version"]).unwrap();
191− assert!(matches!(command, TuiCommand::Version));
192− }
193−
194− #[test]
195− fn parse_defaults() {
196− let command = parse_from(&[]).unwrap();
197− let TuiCommand::Run(config) = command else {
198− panic!("expected run command");
199− };
200−
201− assert_eq!(config.vm_version, env!("CARGO_PKG_VERSION"));
202− assert_eq!(config.max_memory_mb, 2048);
203− assert_eq!(config.cpu_cores, 2);
204− }
205−
206− #[test]
207− fn parse_overrides() {
208− let command = parse_from(&[
209− "--cwd",
210− "/tmp",
211− "--vm-version",
212− "13.1",
213− "--max-memory",
214− "4096",
215− "--cpu-cores",
216− "4",
217− ])
218− .unwrap();
219−
220− let TuiCommand::Run(config) = command else {
221− panic!("expected run command");
222− };
223−
224− assert_eq!(config.cwd, PathBuf::from("/tmp"));
225− assert_eq!(config.vm_version, "13.1");
226− assert_eq!(config.max_memory_mb, 4096);
227− assert_eq!(config.cpu_cores, 4);
228− }
229−
230− #[test]
231− fn parse_rejects_zero_cpu() {
232− let err = parse_from(&["--cpu-cores", "0"]).unwrap_err();
233− assert!(err.to_string().contains("cpu-cores"));
234− }
235−
236− #[test]
237− fn parse_rejects_zero_memory() {
238− let err = parse_from(&["--max-memory", "0"]).unwrap_err();
239− assert!(err.to_string().contains("max-memory"));
240− }
241−
242− #[test]
243− fn parse_rejects_unknown_argument() {
244− let err = parse_from(&["--unknown"]).unwrap_err();
245− assert!(!err.to_string().is_empty());
246− }
247−}
src/lib.rsnew file+5
@@ -0,0 +1,5 @@
1+pub mod session_manager;
2+pub mod tui;
3+pub mod vm;
4+
5+pub use session_manager::{SessionError, SessionManager, SessionRecord};
src/tui.rs+21
@@ -1,6 +1,8 @@
1use std::{
1use std::{
2 io::{self, Write},
2 io::{self, Write},
3+ os::unix::io::OwnedFd,
3 path::PathBuf,
4 path::PathBuf,
5+ sync::{Arc, Mutex},
4};
6};
5
7
6use color_eyre::Result;
8use color_eyre::Result;
@@ -21,6 +23,8 @@ use ratatui::{
21 widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
23 widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
22};
24};
23
25
26+use crate::vm;
27+
24const ASCII_BANNER: [&str; 7] = [
28const ASCII_BANNER: [&str; 7] = [
25 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
29 "██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
26 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
30 "██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -170,6 +174,23 @@ pub fn render_commands_component(app: &mut AppState) -> Result<()> {
170 Ok(())
174 Ok(())
171}
175}
172
176
177+pub fn passthrough_vm_io(
178+ app: Arc<Mutex<AppState>>,
179+ output_monitor: Arc<vm::OutputMonitor>,
180+ vm_output_fd: OwnedFd,
181+ vm_input_fd: OwnedFd,
182+) -> vm::IoContext {
183+ vm::spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, move |line| {
184+ if line == ":help" {
185+ if let Ok(mut locked) = app.lock() {
186+ let _ = render_commands_component(&mut locked);
187+ }
188+ return true;
189+ }
190+ false
191+ })
192+}
193+
173fn render_static_buffer(app: &mut AppState, width: u16) -> Buffer {
194fn render_static_buffer(app: &mut AppState, width: u16) -> Buffer {
174 let layout = compute_page_layout(app, width);
195 let layout = compute_page_layout(app, width);
175 let content_height = layout.total_height.max(1);
196 let content_height = layout.total_height.max(1);
src/main.rs → src/vm.rsrenamed+148−43
@@ -1,7 +1,3 @@
1−mod session_manager;
2−
3−pub use session_manager::{SessionError, SessionManager, SessionRecord};
4−
5use std::{
1use std::{
6 env,
2 env,
7 ffi::OsString,
3 ffi::OsString,
@@ -119,41 +115,26 @@ impl DirectoryShare {
119 }
115 }
120}
116}
121
117
122−fn main() -> Result<(), Box<dyn std::error::Error>> {
118+pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
123 let args = parse_cli()?;
119 let args = parse_cli()?;
124
120
125− if args.version {
121+ if args.version() {
126− println!("Vibe");
122+ print_version();
127− println!("https://github.com/lynaghk/vibe/");
123+ return Ok(());
128− println!("Git SHA: {}", env!("GIT_SHA"));
129− std::process::exit(0);
130 }
124 }
131
125
132− if args.help {
126+ if args.help() {
133− println!(
127+ print_help();
134− "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
128+ return Ok(());
135−
136−vibe [OPTIONS] [disk-image.raw]
137−
138−Options
139−
140− --help Print this help message.
141− --version Print the version (commit SHA).
142− --no-default-mounts Disable all default mounts.
143− --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
144− Defaults to read-write.
145− Errors if host-path does not exist.
146− --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
147− --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
148− --script <path/to/script.sh> Run script in VM.
149− --send <some-command> Type `some-command` followed by newline into the VM.
150− --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
151− If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
152−"
153− );
154− std::process::exit(0);
155 }
129 }
156
130
131+ run_with_args(args, spawn_vm_io)
132+}
133+
134+pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
135+where
136+ F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
137+{
157 ensure_signed();
138 ensure_signed();
158
139
159 let project_root = env::current_dir()?;
140 let project_root = env::current_dir()?;
@@ -239,6 +220,11 @@ Options
239 ),
220 ),
240 DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
221 DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
241 DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
222 DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
223+ DirectoryShare::new(
224+ "/Users/zhangjie/Documents/Code/CompletePrograms/vibebox/.ssh".into(),
225+ "/root/.ssh".into(),
226+ true,
227+ ),
242 ]
228 ]
243 .into_iter()
229 .into_iter()
244 .flatten()
230 .flatten()
@@ -258,16 +244,47 @@ Options
258 // Any user-provided login actions must come after our system ones
244 // Any user-provided login actions must come after our system ones
259 login_actions.extend(args.login_actions);
245 login_actions.extend(args.login_actions);
260
246
261− run_vm(
247+ run_vm_with_io(
262 &disk_path,
248 &disk_path,
263 &login_actions,
249 &login_actions,
264 &directory_shares[..],
250 &directory_shares[..],
265 args.cpu_count,
251 args.cpu_count,
266 args.ram_bytes,
252 args.ram_bytes,
253+ io_handler,
267 )
254 )
268}
255}
269
256
257+pub fn print_help() {
258+ println!(
259+ "Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
260+
261+vibe [OPTIONS] [disk-image.raw]
262+
263+Options
264+
265+ --help Print this help message.
266+ --version Print the version (commit SHA).
267+ --no-default-mounts Disable all default mounts.
268+ --mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
269+ Defaults to read-write.
270+ Errors if host-path does not exist.
271+ --cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
272+ --ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
273+ --script <path/to/script.sh> Run script in VM.
274+ --send <some-command> Type `some-command` followed by newline into the VM.
275+ --expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
276+ If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
277+"
278+ );
279+}
280+
281+pub fn print_version() {
282+ println!("Vibe");
283+ println!("https://github.com/lynaghk/vibe/");
284+ println!("Git SHA: {}", env!("GIT_SHA"));
285+}
286+
270−struct CliArgs {
287+pub struct CliArgs {
271 disk: Option<PathBuf>,
288 disk: Option<PathBuf>,
272 version: bool,
289 version: bool,
273 help: bool,
290 help: bool,
@@ -278,14 +295,43 @@ struct CliArgs {
278 ram_bytes: u64,
295 ram_bytes: u64,
279}
296}
280
297
298+impl CliArgs {
299+ pub fn version(&self) -> bool {
300+ self.version
301+ }
302+
303+ pub fn help(&self) -> bool {
304+ self.help
305+ }
306+
307+ pub fn cpu_count(&self) -> usize {
308+ self.cpu_count
309+ }
310+
311+ pub fn ram_bytes(&self) -> u64 {
312+ self.ram_bytes
313+ }
314+
315+ pub fn ram_mb(&self) -> u64 {
316+ self.ram_bytes / BYTES_PER_MB
317+ }
318+}
319+
281−fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
320+pub fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
321+ parse_cli_from(env::args_os())
322+}
323+
324+pub fn parse_cli_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
325+where
326+ I: IntoIterator<Item = OsString>,
327+{
282 fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
328 fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
283 value
329 value
284 .into_string()
330 .into_string()
285 .map_err(|_| format!("{flag} expects valid UTF-8").into())
331 .map_err(|_| format!("{flag} expects valid UTF-8").into())
286 }
332 }
287
333
288− let mut parser = lexopt::Parser::from_env();
334+ let mut parser = lexopt::Parser::from_iter(args);
289 let mut disk = None;
335 let mut disk = None;
290 let mut version = false;
336 let mut version = false;
291 let mut help = false;
337 let mut help = false;
@@ -621,11 +667,15 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
621 (read_stream.into(), write_stream.into())
667 (read_stream.into(), write_stream.into())
622}
668}
623
669
624−pub fn spawn_vm_io(
670+pub fn spawn_vm_io_with_line_handler<F>(
625 output_monitor: Arc<OutputMonitor>,
671 output_monitor: Arc<OutputMonitor>,
626 vm_output_fd: OwnedFd,
672 vm_output_fd: OwnedFd,
627 vm_input_fd: OwnedFd,
673 vm_input_fd: OwnedFd,
674+ mut on_line: F,
628−) -> IoContext {
675+) -> IoContext
676+where
677+ F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
678+{
629 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
679 let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
630
680
631 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
681 // raw_guard is set when we've put the user's terminal into raw mode because we've attached stdin/stdout to the VM.
@@ -679,15 +729,41 @@ pub fn spawn_vm_io(
679
729
680 move || {
730 move || {
681 let mut buf = [0u8; 1024];
731 let mut buf = [0u8; 1024];
732+ let mut pending_command: Vec<u8> = Vec::new();
733+ let mut command_mode = false;
682 loop {
734 loop {
683 match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
735 match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
684 PollResult::Shutdown | PollResult::Error => break,
736 PollResult::Shutdown | PollResult::Error => break,
685 PollResult::Spurious => continue,
737 PollResult::Spurious => continue,
686 PollResult::Ready(bytes) => {
738 PollResult::Ready(bytes) => {
739+ let mut send_buf: Vec<u8> = Vec::new();
740+ for &b in bytes {
741+ if pending_command.is_empty() && !command_mode && b == b':' {
742+ command_mode = true;
743+ }
744+
745+ if command_mode {
746+ pending_command.push(b);
747+ } else {
748+ send_buf.push(b);
749+ }
750+
751+ if b == b'\n' && command_mode {
752+ let line = String::from_utf8_lossy(&pending_command);
753+ let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
754+ let consumed = on_line(trimmed);
755+ if !consumed {
756+ send_buf.extend_from_slice(&pending_command);
757+ }
758+ pending_command.clear();
759+ command_mode = false;
760+ }
761+ }
687 if raw_guard.lock().unwrap().is_none() {
762 if raw_guard.lock().unwrap().is_none() {
688 continue;
763 continue;
689 }
764 }
690− if input_tx.send(VmInput::Bytes(bytes.to_vec())).is_err() {
765+ if !send_buf.is_empty() && input_tx.send(VmInput::Bytes(send_buf)).is_err()
766+ {
691 break;
767 break;
692 }
768 }
693 }
769 }
@@ -753,6 +829,14 @@ pub fn spawn_vm_io(
753 }
829 }
754}
830}
755
831
832+pub fn spawn_vm_io(
833+ output_monitor: Arc<OutputMonitor>,
834+ vm_output_fd: OwnedFd,
835+ vm_input_fd: OwnedFd,
836+) -> IoContext {
837+ spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
838+}
839+
756impl IoContext {
840impl IoContext {
757 pub fn shutdown(self) {
841 pub fn shutdown(self) {
758 let _ = self.input_tx.send(VmInput::Shutdown);
842 let _ = self.input_tx.send(VmInput::Shutdown);
@@ -954,13 +1038,17 @@ fn spawn_login_actions_thread(
954 })
1038 })
955}
1039}
956
1040
957−fn run_vm(
1041+fn run_vm_with_io<F>(
958 disk_path: &Path,
1042 disk_path: &Path,
959 login_actions: &[LoginAction],
1043 login_actions: &[LoginAction],
960 directory_shares: &[DirectoryShare],
1044 directory_shares: &[DirectoryShare],
961 cpu_count: usize,
1045 cpu_count: usize,
962 ram_bytes: u64,
1046 ram_bytes: u64,
1047+ io_handler: F,
963−) -> Result<(), Box<dyn std::error::Error>> {
1048+) -> Result<(), Box<dyn std::error::Error>>
1049+where
1050+ F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
1051+{
964 let (vm_reads_from, we_write_to) = create_pipe();
1052 let (vm_reads_from, we_write_to) = create_pipe();
965 let (we_read_from, vm_writes_to) = create_pipe();
1053 let (we_read_from, vm_writes_to) = create_pipe();
966
1054
@@ -1021,7 +1109,7 @@ fn run_vm(
1021 println!("VM booting...");
1109 println!("VM booting...");
1022
1110
1023 let output_monitor = Arc::new(OutputMonitor::default());
1111 let output_monitor = Arc::new(OutputMonitor::default());
1024− let io_ctx = spawn_vm_io(output_monitor.clone(), we_read_from, we_write_to);
1112+ let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
1025
1113
1026 let mut all_login_actions = vec![
1114 let mut all_login_actions = vec![
1027 Expect {
1115 Expect {
@@ -1112,6 +1200,23 @@ fn run_vm(
1112 exit_result
1200 exit_result
1113}
1201}
1114
1202
1203+fn run_vm(
1204+ disk_path: &Path,
1205+ login_actions: &[LoginAction],
1206+ directory_shares: &[DirectoryShare],
1207+ cpu_count: usize,
1208+ ram_bytes: u64,
1209+) -> Result<(), Box<dyn std::error::Error>> {
1210+ run_vm_with_io(
1211+ disk_path,
1212+ login_actions,
1213+ directory_shares,
1214+ cpu_count,
1215+ ram_bytes,
1216+ spawn_vm_io,
1217+ )
1218+}
1219+
1115fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1220fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
1116 let abs_path = if path.is_absolute() {
1221 let abs_path = if path.is_absolute() {
1117 path.to_path_buf()
1222 path.to_path_buf()