feat: wired up vm and tui

This commit is contained in:
robcholz
2026-02-06 17:04:36 -05:00
parent e3ba0a0072
commit 52bcd88d7e
7 changed files with 240 additions and 294 deletions
+11 -1
View File
@@ -88,4 +88,14 @@ They are also stored per project, in `vibebox.toml`
- use `:explain` to display:
- mounts: host_path → guest_path, ro/rw
- network: mode (allowlist/blocklist) and entries
- storage: paths to vibebox.toml and .vibebox/ (relative from the project_dir)
- storage: paths to vibebox.toml and .vibebox/ (relative from the project_dir)
## Connection
### SSH
- In Project cache, generate and store ssh pair
- In provisioning, install and enable openssh-server in VM
- Mount ssh pair to VM when starting up
- get ipv4 address of VM, store it to project cache
- and connect to VM via ssh with ip and ssh key
+6 -3
View File
@@ -29,10 +29,13 @@
## TUI
1. [ ] Fix the terminal component height issue.
2. [ ] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
1. [x] Fix the terminal component height issue.
2. [x] Fix the input field that does not expand its height (currently, it just roll the text horizontally). The
inputfield it should not be scrollable.
## Integration
1. [ ] Integrate VM and SessionManager together.
1. [x] Wire up the vm and tui.
2. [ ] Use ssh to connect to vm.
3. [ ] wire up SessionManager.
4. [ ] VM should be separated by per-session VM daemon process (only accepts if to shutdown vm and itself).
+49
View File
@@ -0,0 +1,49 @@
use std::{
env,
io::{self, Write},
sync::{Arc, Mutex},
};
use color_eyre::Result;
use vibebox::tui::{AppState, VmInfo};
use vibebox::{tui, vm};
fn main() -> Result<()> {
color_eyre::install()?;
let args = vm::parse_cli().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
if args.version() {
vm::print_version();
return Ok(());
}
if args.help() {
vm::print_help();
return Ok(());
}
let vm_info = VmInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
max_memory_mb: args.ram_mb(),
cpu_cores: args.cpu_count(),
};
let cwd = env::current_dir().map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
let app = Arc::new(Mutex::new(AppState::new(cwd, vm_info)));
{
let mut locked = app.lock().expect("app state poisoned");
tui::render_tui_once(&mut locked)?;
}
{
let mut stdout = io::stdout().lock();
writeln!(stdout)?;
stdout.flush()?;
}
vm::run_with_args(args, |output_monitor, vm_output_fd, vm_input_fd| {
tui::passthrough_vm_io(app.clone(), output_monitor, vm_output_fd, vm_input_fd)
})
.map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
Ok(())
}
-247
View File
@@ -1,247 +0,0 @@
use std::{
env,
ffi::OsString,
io::{self, Read, Write},
path::PathBuf,
};
use color_eyre::Result;
use lexopt::prelude::*;
#[path = "../tui.rs"]
mod tui;
use tui::{AppState, VmInfo};
#[derive(Debug, Clone, PartialEq, Eq)]
struct TuiConfig {
cwd: PathBuf,
vm_version: String,
max_memory_mb: u64,
cpu_cores: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TuiCommand {
Run(TuiConfig),
Help,
Version,
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error("{0}")]
Message(String),
#[error(transparent)]
Lexopt(#[from] lexopt::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}
fn main() -> Result<()> {
color_eyre::install()?;
let command = parse_args(env::args_os())?;
match command {
TuiCommand::Help => {
print_help();
}
TuiCommand::Version => {
println!("vibebox-tui {}", env!("CARGO_PKG_VERSION"));
}
TuiCommand::Run(config) => {
let vm_info = VmInfo {
version: config.vm_version,
max_memory_mb: config.max_memory_mb,
cpu_cores: config.cpu_cores,
};
let mut app = AppState::new(config.cwd, vm_info);
tui::render_tui_once(&mut app)?;
{
let mut stdout = io::stdout().lock();
writeln!(stdout)?;
stdout.flush()?;
}
passthrough_stdio(&mut app)?;
}
}
Ok(())
}
fn passthrough_stdio(app: &mut AppState) -> io::Result<()> {
let mut stdin = io::stdin().lock();
let mut buf = [0u8; 8192];
let mut line_buf: Vec<u8> = Vec::new();
loop {
let n = stdin.read(&mut buf)?;
if n == 0 {
break;
}
for &b in &buf[..n] {
line_buf.push(b);
if b == b'\n' {
let line = String::from_utf8_lossy(&line_buf);
let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
if trimmed == ":help" {
let _ = tui::render_commands_component(app);
}
line_buf.clear();
}
}
}
Ok(())
}
fn print_help() {
println!(
"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"
);
}
fn parse_args<I>(args: I) -> Result<TuiCommand, CliError>
where
I: IntoIterator<Item = OsString>,
{
fn os_to_string(value: OsString, flag: &str) -> Result<String, CliError> {
value
.into_string()
.map_err(|_| CliError::Message(format!("{flag} expects valid UTF-8")))
}
let mut parser = lexopt::Parser::from_iter(args);
let mut cwd: Option<PathBuf> = None;
let mut vm_version = env!("CARGO_PKG_VERSION").to_string();
let mut max_memory_mb: u64 = 2048;
let mut cpu_cores: usize = 2;
while let Some(arg) = parser.next()? {
match arg {
Long("help") | Short('h') => return Ok(TuiCommand::Help),
Long("version") => return Ok(TuiCommand::Version),
Long("cwd") => {
let value = os_to_string(parser.value()?, "--cwd")?;
cwd = Some(PathBuf::from(value));
}
Long("vm-version") => {
vm_version = os_to_string(parser.value()?, "--vm-version")?;
}
Long("max-memory") => {
let value: u64 = os_to_string(parser.value()?, "--max-memory")?
.parse()
.map_err(|_| {
CliError::Message("--max-memory expects an integer".to_string())
})?;
if value == 0 {
return Err(CliError::Message("--max-memory must be >= 1".to_string()));
}
max_memory_mb = value;
}
Long("cpu-cores") => {
let value: usize = os_to_string(parser.value()?, "--cpu-cores")?
.parse()
.map_err(|_| CliError::Message("--cpu-cores expects an integer".to_string()))?;
if value == 0 {
return Err(CliError::Message("--cpu-cores must be >= 1".to_string()));
}
cpu_cores = value;
}
Value(value) => {
return Err(CliError::Message(format!(
"unexpected argument: {}",
value.to_string_lossy()
)));
}
_ => return Err(CliError::Message(arg.unexpected().to_string())),
}
}
let cwd = match cwd {
Some(dir) => dir,
None => env::current_dir()?,
};
Ok(TuiCommand::Run(TuiConfig {
cwd,
vm_version,
max_memory_mb,
cpu_cores,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_from(args: &[&str]) -> Result<TuiCommand, CliError> {
let mut argv = vec![OsString::from("vibebox-tui")];
argv.extend(args.iter().map(OsString::from));
parse_args(argv)
}
#[test]
fn parse_help_short_circuit() {
let command = parse_from(&["--help"]).unwrap();
assert!(matches!(command, TuiCommand::Help));
}
#[test]
fn parse_version_short_circuit() {
let command = parse_from(&["--version"]).unwrap();
assert!(matches!(command, TuiCommand::Version));
}
#[test]
fn parse_defaults() {
let command = parse_from(&[]).unwrap();
let TuiCommand::Run(config) = command else {
panic!("expected run command");
};
assert_eq!(config.vm_version, env!("CARGO_PKG_VERSION"));
assert_eq!(config.max_memory_mb, 2048);
assert_eq!(config.cpu_cores, 2);
}
#[test]
fn parse_overrides() {
let command = parse_from(&[
"--cwd",
"/tmp",
"--vm-version",
"13.1",
"--max-memory",
"4096",
"--cpu-cores",
"4",
])
.unwrap();
let TuiCommand::Run(config) = command else {
panic!("expected run command");
};
assert_eq!(config.cwd, PathBuf::from("/tmp"));
assert_eq!(config.vm_version, "13.1");
assert_eq!(config.max_memory_mb, 4096);
assert_eq!(config.cpu_cores, 4);
}
#[test]
fn parse_rejects_zero_cpu() {
let err = parse_from(&["--cpu-cores", "0"]).unwrap_err();
assert!(err.to_string().contains("cpu-cores"));
}
#[test]
fn parse_rejects_zero_memory() {
let err = parse_from(&["--max-memory", "0"]).unwrap_err();
assert!(err.to_string().contains("max-memory"));
}
#[test]
fn parse_rejects_unknown_argument() {
let err = parse_from(&["--unknown"]).unwrap_err();
assert!(!err.to_string().is_empty());
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod session_manager;
pub mod tui;
pub mod vm;
pub use session_manager::{SessionError, SessionManager, SessionRecord};
+21
View File
@@ -1,6 +1,8 @@
use std::{
io::{self, Write},
os::unix::io::OwnedFd,
path::PathBuf,
sync::{Arc, Mutex},
};
use color_eyre::Result;
@@ -21,6 +23,8 @@ use ratatui::{
widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
};
use crate::vm;
const ASCII_BANNER: [&str; 7] = [
"██╗ ██╗██╗██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗",
"██║ ██║██║██╔══██╗██╔════╝██╔══██╗██╔═══██╗╚██╗██╔╝",
@@ -170,6 +174,23 @@ pub fn render_commands_component(app: &mut AppState) -> Result<()> {
Ok(())
}
pub fn passthrough_vm_io(
app: Arc<Mutex<AppState>>,
output_monitor: Arc<vm::OutputMonitor>,
vm_output_fd: OwnedFd,
vm_input_fd: OwnedFd,
) -> vm::IoContext {
vm::spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, move |line| {
if line == ":help" {
if let Ok(mut locked) = app.lock() {
let _ = render_commands_component(&mut locked);
}
return true;
}
false
})
}
fn render_static_buffer(app: &mut AppState, width: u16) -> Buffer {
let layout = compute_page_layout(app, width);
let content_height = layout.total_height.max(1);
+148 -43
View File
@@ -1,7 +1,3 @@
mod session_manager;
pub use session_manager::{SessionError, SessionManager, SessionRecord};
use std::{
env,
ffi::OsString,
@@ -119,41 +115,26 @@ impl DirectoryShare {
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
pub fn run_cli() -> Result<(), Box<dyn std::error::Error>> {
let args = parse_cli()?;
if args.version {
println!("Vibe");
println!("https://github.com/lynaghk/vibe/");
println!("Git SHA: {}", env!("GIT_SHA"));
std::process::exit(0);
if args.version() {
print_version();
return Ok(());
}
if args.help {
println!(
"Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
vibe [OPTIONS] [disk-image.raw]
Options
--help Print this help message.
--version Print the version (commit SHA).
--no-default-mounts Disable all default mounts.
--mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
Defaults to read-write.
Errors if host-path does not exist.
--cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
--ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
--script <path/to/script.sh> Run script in VM.
--send <some-command> Type `some-command` followed by newline into the VM.
--expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
"
);
std::process::exit(0);
if args.help() {
print_help();
return Ok(());
}
run_with_args(args, spawn_vm_io)
}
pub fn run_with_args<F>(args: CliArgs, io_handler: F) -> Result<(), Box<dyn std::error::Error>>
where
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
{
ensure_signed();
let project_root = env::current_dir()?;
@@ -239,6 +220,11 @@ Options
),
DirectoryShare::new(home.join(".codex"), "/root/.codex".into(), false),
DirectoryShare::new(home.join(".claude"), "/root/.claude".into(), false),
DirectoryShare::new(
"/Users/zhangjie/Documents/Code/CompletePrograms/vibebox/.ssh".into(),
"/root/.ssh".into(),
true,
),
]
.into_iter()
.flatten()
@@ -258,16 +244,47 @@ Options
// Any user-provided login actions must come after our system ones
login_actions.extend(args.login_actions);
run_vm(
run_vm_with_io(
&disk_path,
&login_actions,
&directory_shares[..],
args.cpu_count,
args.ram_bytes,
io_handler,
)
}
struct CliArgs {
pub fn print_help() {
println!(
"Vibe is a quick way to spin up a Linux virtual machine on Mac to sandbox LLM agents.
vibe [OPTIONS] [disk-image.raw]
Options
--help Print this help message.
--version Print the version (commit SHA).
--no-default-mounts Disable all default mounts.
--mount host-path:guest-path[:read-only | :read-write] Mount `host-path` inside VM at `guest-path`.
Defaults to read-write.
Errors if host-path does not exist.
--cpus <count> Number of virtual CPUs (default {DEFAULT_CPU_COUNT}).
--ram <megabytes> RAM size in megabytes (default {DEFAULT_RAM_MB}).
--script <path/to/script.sh> Run script in VM.
--send <some-command> Type `some-command` followed by newline into the VM.
--expect <string> [timeout-seconds] Wait for `string` to appear in console output before executing next `--script` or `--send`.
If `string` does not appear within timeout (default 30 seconds), shutdown VM with error.
"
);
}
pub fn print_version() {
println!("Vibe");
println!("https://github.com/lynaghk/vibe/");
println!("Git SHA: {}", env!("GIT_SHA"));
}
pub struct CliArgs {
disk: Option<PathBuf>,
version: bool,
help: bool,
@@ -278,14 +295,43 @@ struct CliArgs {
ram_bytes: u64,
}
fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
impl CliArgs {
pub fn version(&self) -> bool {
self.version
}
pub fn help(&self) -> bool {
self.help
}
pub fn cpu_count(&self) -> usize {
self.cpu_count
}
pub fn ram_bytes(&self) -> u64 {
self.ram_bytes
}
pub fn ram_mb(&self) -> u64 {
self.ram_bytes / BYTES_PER_MB
}
}
pub fn parse_cli() -> Result<CliArgs, Box<dyn std::error::Error>> {
parse_cli_from(env::args_os())
}
pub fn parse_cli_from<I>(args: I) -> Result<CliArgs, Box<dyn std::error::Error>>
where
I: IntoIterator<Item = OsString>,
{
fn os_to_string(value: OsString, flag: &str) -> Result<String, Box<dyn std::error::Error>> {
value
.into_string()
.map_err(|_| format!("{flag} expects valid UTF-8").into())
}
let mut parser = lexopt::Parser::from_env();
let mut parser = lexopt::Parser::from_iter(args);
let mut disk = None;
let mut version = false;
let mut help = false;
@@ -621,11 +667,15 @@ pub fn create_pipe() -> (OwnedFd, OwnedFd) {
(read_stream.into(), write_stream.into())
}
pub fn spawn_vm_io(
pub fn spawn_vm_io_with_line_handler<F>(
output_monitor: Arc<OutputMonitor>,
vm_output_fd: OwnedFd,
vm_input_fd: OwnedFd,
) -> IoContext {
mut on_line: F,
) -> IoContext
where
F: FnMut(&str) -> bool + ::std::marker::Send + 'static,
{
let (input_tx, input_rx): (Sender<VmInput>, Receiver<VmInput>) = mpsc::channel();
// 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(
move || {
let mut buf = [0u8; 1024];
let mut pending_command: Vec<u8> = Vec::new();
let mut command_mode = false;
loop {
match poll_with_wakeup(libc::STDIN_FILENO, wakeup_read.as_raw_fd(), &mut buf) {
PollResult::Shutdown | PollResult::Error => break,
PollResult::Spurious => continue,
PollResult::Ready(bytes) => {
let mut send_buf: Vec<u8> = Vec::new();
for &b in bytes {
if pending_command.is_empty() && !command_mode && b == b':' {
command_mode = true;
}
if command_mode {
pending_command.push(b);
} else {
send_buf.push(b);
}
if b == b'\n' && command_mode {
let line = String::from_utf8_lossy(&pending_command);
let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
let consumed = on_line(trimmed);
if !consumed {
send_buf.extend_from_slice(&pending_command);
}
pending_command.clear();
command_mode = false;
}
}
if raw_guard.lock().unwrap().is_none() {
continue;
}
if input_tx.send(VmInput::Bytes(bytes.to_vec())).is_err() {
if !send_buf.is_empty() && input_tx.send(VmInput::Bytes(send_buf)).is_err()
{
break;
}
}
@@ -753,6 +829,14 @@ pub fn spawn_vm_io(
}
}
pub fn spawn_vm_io(
output_monitor: Arc<OutputMonitor>,
vm_output_fd: OwnedFd,
vm_input_fd: OwnedFd,
) -> IoContext {
spawn_vm_io_with_line_handler(output_monitor, vm_output_fd, vm_input_fd, |_| false)
}
impl IoContext {
pub fn shutdown(self) {
let _ = self.input_tx.send(VmInput::Shutdown);
@@ -954,13 +1038,17 @@ fn spawn_login_actions_thread(
})
}
fn run_vm(
fn run_vm_with_io<F>(
disk_path: &Path,
login_actions: &[LoginAction],
directory_shares: &[DirectoryShare],
cpu_count: usize,
ram_bytes: u64,
) -> Result<(), Box<dyn std::error::Error>> {
io_handler: F,
) -> Result<(), Box<dyn std::error::Error>>
where
F: FnOnce(Arc<OutputMonitor>, OwnedFd, OwnedFd) -> IoContext,
{
let (vm_reads_from, we_write_to) = create_pipe();
let (we_read_from, vm_writes_to) = create_pipe();
@@ -1021,7 +1109,7 @@ fn run_vm(
println!("VM booting...");
let output_monitor = Arc::new(OutputMonitor::default());
let io_ctx = spawn_vm_io(output_monitor.clone(), we_read_from, we_write_to);
let io_ctx = io_handler(output_monitor.clone(), we_read_from, we_write_to);
let mut all_login_actions = vec![
Expect {
@@ -1112,6 +1200,23 @@ fn run_vm(
exit_result
}
fn run_vm(
disk_path: &Path,
login_actions: &[LoginAction],
directory_shares: &[DirectoryShare],
cpu_count: usize,
ram_bytes: u64,
) -> Result<(), Box<dyn std::error::Error>> {
run_vm_with_io(
disk_path,
login_actions,
directory_shares,
cpu_count,
ram_bytes,
spawn_vm_io,
)
}
fn nsurl_from_path(path: &Path) -> Result<Retained<NSURL>, Box<dyn std::error::Error>> {
let abs_path = if path.is_absolute() {
path.to_path_buf()