From 096da68d1692015a89a3f6e0b79186c0171cf474 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 14:58:17 +0200 Subject: [PATCH 01/16] Update docs on passthrough --- docs/guides/passthrough.md | 6 +++++- docs/guides/tutorial.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/guides/passthrough.md b/docs/guides/passthrough.md index 93dce5c..25116d6 100644 --- a/docs/guides/passthrough.md +++ b/docs/guides/passthrough.md @@ -35,7 +35,7 @@ and streams stdout, stderr, and the exit code back. │ │ │ │ │ │ └─ symlink → bunkerbox-vscomm │ │ │ │ │ -│ │ │ vsock (port 9999) │ +│ │ │ toolchain vsock (port 9999) │ │ │ ▼ │ │ │ "run make build in /workspace" │ └────┼───────────────────────────────────────────────┘ @@ -57,6 +57,10 @@ daemon runs inside the overlay workspace, so all output — compiled binaries, generated files, test results — lands in the upper layer of the overlay and is auto-synced back to your real repo when the container exits. +The command channel uses vsock port `9999`. TUI status and dialog commands use +the separate `bunkerbox-status` client and vsock port `10000`; the +`bunkerbox-vscomm` command client never opens the TUI channel. + ## Configuration The whitelist lives in `.bunkerbox/project.conf` under the `passthrough` key: diff --git a/docs/guides/tutorial.md b/docs/guides/tutorial.md index aff838e..bde2c38 100644 --- a/docs/guides/tutorial.md +++ b/docs/guides/tutorial.md @@ -250,7 +250,7 @@ workspace and re-creates it. │ ~/bunkerbox-tutorial/.bunkerbox/ ← overlay upper layer │ │ .bunkerbox/workspace/ ← where AI actually writes │ │ │ -│ Bunkerbox daemon listens on vsock port 9999. │ +│ Bunkerbox toolchain daemon listens on vsock port 9999. │ │ When AI calls `cargo build`: │ │ → checks whitelist ("cargo *" ✓) │ │ → runs `cargo build` inside .bunkerbox/workspace/ │ From 9b261b764e909758f7f07599f654b72753e6cec9 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 14:58:31 +0200 Subject: [PATCH 02/16] Split ports on vsocks --- src/bin/bunkerbox-status.rs | 4 +-- src/bin/bunkerbox-vscomm.rs | 23 ++------------- src/daemon.rs | 50 ++++++++++++++++++-------------- src/kata.rs | 4 +-- src/main.rs | 57 ++++++++++++++++++++++++------------- src/vscomm/mod.rs | 16 +++++++++-- 6 files changed, 86 insertions(+), 68 deletions(-) diff --git a/src/bin/bunkerbox-status.rs b/src/bin/bunkerbox-status.rs index f1643f9..51833b9 100644 --- a/src/bin/bunkerbox-status.rs +++ b/src/bin/bunkerbox-status.rs @@ -5,7 +5,7 @@ use std::env; use std::io::{self, Read, Write}; use std::mem; use std::os::unix::io::RawFd; -use vscomm::{Frame, FrameType, STATUS_PORT}; +use vscomm::{Frame, FrameType, TUI_STATUS_PORT}; const HOST_CID: u32 = 2; @@ -67,7 +67,7 @@ fn run() -> Result<(), String> { let payload = vscomm::encode_ui_payload(widget, command, &options, &value); let frame = Frame::new(FrameType::UiCommand, payload); - let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("vsock connect: {e}"))?; + let mut stream = vsock_connect(HOST_CID, TUI_STATUS_PORT).map_err(|e| format!("TUI vsock connect: {e}"))?; frame.write(&mut stream).map_err(|e| format!("send: {e}"))?; stream.flush().map_err(|e| format!("flush: {e}"))?; } diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 8174fcf..3a9d3da 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -8,7 +8,7 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{encode_ui_payload, ExecRequest, Frame, FrameType, STATUS_PORT, VSCOMM_BIN_DIR, VSOCK_PORT}; +use vscomm::{ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, VSCOMM_BIN_DIR}; const HOST_CID: u32 = 2; @@ -34,17 +34,13 @@ fn run() -> Result<(), String> { .ok_or_else(|| "bunkerbox-vscomm must be invoked via symlink (not directly)".to_string())? .to_string(); - let label = if args.len() > 1 { format!("{} {}", invoked_as, args[1..].join(" ")) } else { invoked_as.clone() }; - - send_status(&format!("Running: {label}")); - let cwd = env::current_dir().map_err(|e| format!("cwd: {e}"))?; let env_vars: Vec<(String, String)> = env::vars().collect(); let req = ExecRequest { cwd: cwd.to_string_lossy().to_string(), command: invoked_as, args: args[1..].to_vec(), env: env_vars }; let frame = Frame::new(FrameType::ExecReq, req.serialize()); - let mut stream = vsock_connect(HOST_CID, VSOCK_PORT).map_err(|e| format!("vsock connect: {e}"))?; + let mut stream = vsock_connect(HOST_CID, TOOLCHAIN_PORT).map_err(|e| format!("toolchain vsock connect: {e}"))?; frame.write(&mut stream).map_err(|e| format!("send request: {e}"))?; stream.flush().map_err(|e| format!("flush: {e}"))?; @@ -62,7 +58,6 @@ fn run() -> Result<(), String> { io::stderr().flush().map_err(|e| format!("flush stderr: {e}"))?; } FrameType::Exit => { - send_status(&format!("Done: {label}")); if response.payload.len() >= 4 { let code = i32::from_le_bytes([response.payload[0], response.payload[1], response.payload[2], response.payload[3]]); std::process::exit(code); @@ -178,20 +173,6 @@ fn command_exists_in_path_except(cmd: &str, except: &Path) -> bool { false } -fn send_status(msg: &str) { - let result = (|| -> Result<(), String> { - let payload = encode_ui_payload("status", "set", "", msg); - let frame = Frame::new(FrameType::UiCommand, payload); - let mut stream = vsock_connect(HOST_CID, STATUS_PORT).map_err(|e| format!("status connect: {e}"))?; - frame.write(&mut stream).map_err(|e| format!("status write: {e}"))?; - stream.flush().map_err(|e| format!("status flush: {e}"))?; - Ok(()) - })(); - if let Err(e) = result { - eprintln!("bunkerbox-vscomm: status: {e}"); - } -} - fn vsock_connect(cid: u32, port: u32) -> io::Result { unsafe { let fd = libc::socket(libc::AF_VSOCK, libc::SOCK_STREAM, 0); diff --git a/src/daemon.rs b/src/daemon.rs index eceb425..930fd0d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,7 +1,7 @@ use crate::cfg::EnvMode; use crate::proxy::FilterProxy; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; -use crate::vscomm::{ExecRequest, Frame, FrameType, VSOCK_PORT}; +use crate::vscomm::{ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; @@ -55,8 +55,11 @@ impl VsockDaemon { let session = Arc::new(VsockSession { passthrough: Arc::new(passthrough), env_mode, workspace, merged_profile, has_proxy }); + let listener = tokio_vsock::VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, TOOLCHAIN_PORT)) + .map_err(|e| format!("failed to bind toolchain vsock port {TOOLCHAIN_PORT}: {e}"))?; + let join_handle = tokio::spawn(async move { - let result = daemon_loop(session, shutdown_rx).await; + let result = daemon_loop(session, listener, shutdown_rx).await; if let Err(err) = result { eprintln!("bunkerbox: vsock daemon: {err}"); } @@ -74,19 +77,9 @@ impl VsockDaemon { } } -async fn daemon_loop(session: Arc, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>) -> Result<(), String> { - use tokio_vsock::VsockListener; - - let listener = match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, VSOCK_PORT)) { - Ok(l) => l, - Err(_e) => { - // TODO: route to log socket - // eprintln!("bunkerbox: vsock unavailable (passthrough disabled): {e}"); - let _ = shutdown_rx.await; - return Ok(()); - } - }; - +async fn daemon_loop( + session: Arc, listener: tokio_vsock::VsockListener, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, +) -> Result<(), String> { loop { tokio::select! { result = listener.accept() => { @@ -94,9 +87,8 @@ async fn daemon_loop(session: Arc, mut shutdown_rx: tokio::sync::o Ok((stream, _peer)) => { let session = session.clone(); tokio::spawn(async move { - if let Err(_err) = handle_connection(stream, &session).await { - // TODO: route to log socket - // eprintln!("bunkerbox: vsock session error: {err}"); + if let Err(err) = handle_connection(stream, &session).await { + eprintln!("bunkerbox: toolchain vsock session failed: {err}"); } }); } @@ -126,6 +118,19 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess return Ok(()); } + let command = req.command.clone(); + if let Err(err) = execute_request(&mut writer, session, &req).await { + eprintln!("bunkerbox: toolchain command '{command}' failed: {err}"); + let msg = format!("bunkerbox-vscomm: {err}\n"); + let _ = write_frame(&mut writer, &Frame::new(FrameType::Stderr, msg.into_bytes())).await; + let _ = write_frame(&mut writer, &Frame::new(FrameType::Exit, 1i32.to_le_bytes().to_vec())).await; + return Err(err); + } + + Ok(()) +} + +async fn execute_request(writer: &mut W, session: &VsockSession, req: &ExecRequest) -> Result<(), String> { let sandbox_cwd = req.cwd.clone(); let host_cwd = if req.cwd.starts_with("/workspace") { session.workspace.join(req.cwd.strip_prefix("/workspace").unwrap_or(&req.cwd).trim_start_matches('/')) @@ -133,10 +138,11 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess PathBuf::from(&req.cwd) }; - let mut cmd = build_command(session, &req, &host_cwd, &sandbox_cwd)?; + let mut cmd = build_command(session, req, &host_cwd, &sandbox_cwd)?; cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); + cmd.kill_on_drop(true); let mut child = cmd.spawn().map_err(|e| format!("spawn {}: {e}", req.command))?; @@ -153,12 +159,12 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess tokio::select! { chunk = stdout_rx.recv() => { if let Some(data) = chunk { - write_frame(&mut writer, &Frame::new(FrameType::Stdout, data)).await?; + write_frame(writer, &Frame::new(FrameType::Stdout, data)).await?; } } chunk = stderr_rx.recv() => { if let Some(data) = chunk { - write_frame(&mut writer, &Frame::new(FrameType::Stderr, data)).await?; + write_frame(writer, &Frame::new(FrameType::Stderr, data)).await?; } } } @@ -170,7 +176,7 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess let status = child.wait().await.map_err(|e| format!("wait {}: {e}", req.command))?; let exit_code = status.code().unwrap_or(-1); - write_frame(&mut writer, &Frame::new(FrameType::Exit, exit_code.to_le_bytes().to_vec())).await?; + write_frame(writer, &Frame::new(FrameType::Exit, exit_code.to_le_bytes().to_vec())).await?; stdout_task.await.map_err(|e| format!("stdout task: {e}"))?; stderr_task.await.map_err(|e| format!("stderr task: {e}"))?; diff --git a/src/kata.rs b/src/kata.rs index 5803512..2cac0da 100644 --- a/src/kata.rs +++ b/src/kata.rs @@ -1,5 +1,5 @@ use crate::cfg::{HomeMode, NetworkMode, RuntimeConfig}; -use crate::vscomm::VSOCK_PORT; +use crate::vscomm::TOOLCHAIN_PORT; use crate::workspace::WorkspaceHandle; use aes_gcm::aead::consts::U12; use aes_gcm::aead::Aead; @@ -190,7 +190,7 @@ pub fn run( }; if vsock_enabled { - container_env.push(format!("BUNKERBOX_VSOCK_PORT={VSOCK_PORT}")); + container_env.push(format!("BUNKERBOX_TOOLCHAIN_PORT={TOOLCHAIN_PORT}")); } if let Some(ref cmds) = config.command { diff --git a/src/main.rs b/src/main.rs index 1283310..d3aafaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -190,6 +190,8 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option>) { - use std::time::Duration; - use tokio::io::AsyncReadExt; - use tokio_vsock::VsockListener; +struct StatusListener { + join_handle: tokio::task::JoinHandle<()>, + shutdown: tokio::sync::oneshot::Sender<()>, +} - let listener = loop { - match VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, vscomm::STATUS_PORT)) { - Ok(l) => break l, - Err(e) => { - eprintln!("bunkerbox: vsock status listener bind failed ({e}), retrying..."); - std::thread::sleep(Duration::from_millis(500)); - } - } - }; +impl StatusListener { + async fn shutdown(self) { + let _ = self.shutdown.send(()); + let _ = self.join_handle.await; + } +} + +fn start_status_listener(overlay: Arc>) -> Result { + let listener = tokio_vsock::VsockListener::bind(tokio_vsock::VsockAddr::new(libc::VMADDR_CID_ANY, vscomm::TUI_STATUS_PORT)) + .map_err(|e| format!("failed to bind TUI status vsock port {}: {e}", vscomm::TUI_STATUS_PORT))?; + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let join_handle = tokio::spawn(async move { + status_listener(listener, overlay, shutdown_rx).await; + }); + Ok(StatusListener { join_handle, shutdown }) +} + +async fn status_listener( + listener: tokio_vsock::VsockListener, overlay: Arc>, mut shutdown_rx: tokio::sync::oneshot::Receiver<()>, +) { + use tokio::io::AsyncReadExt; loop { - let (mut stream, _peer) = match listener.accept().await { - Ok(c) => c, - Err(_) => continue, + let (mut stream, _peer) = tokio::select! { + result = listener.accept() => match result { + Ok(c) => c, + Err(_) => continue, + }, + _ = &mut shutdown_rx => break, }; let overlay = overlay.clone(); diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index ea5b843..e97dea2 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -4,8 +4,9 @@ use std::io::{self, Read, Write}; pub mod buildsys; -pub const VSOCK_PORT: u32 = 9999; -pub const STATUS_PORT: u32 = 9998; +pub const TOOLCHAIN_PORT: u32 = 9999; +// Keep UI traffic on a separate vsock endpoint from command execution. +pub const TUI_STATUS_PORT: u32 = 10000; pub const VSCOMM_BIN_DIR: &str = "/usr/local/bunkerbox/bin"; #[repr(u16)] @@ -214,3 +215,14 @@ pub fn parse_triggers(options: &str) -> Vec { Vec::new() } } + +#[cfg(test)] +mod tests { + use super::{FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT}; + + #[test] + fn execution_and_tui_channels_are_distinct() { + assert_ne!(TOOLCHAIN_PORT, TUI_STATUS_PORT); + assert_ne!(FrameType::ExecReq as u16, FrameType::UiCommand as u16); + } +} From 40cf5e02b78ace2aa63392e0dac9b4f3c9bca12a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 17:46:49 +0200 Subject: [PATCH 03/16] Finalise vsock separation, remove null-terminated command bug --- src/bin/bunkerbox-vscomm.rs | 3 +- src/daemon.rs | 20 ++++- src/main.rs | 166 +++++++++++++++++++++++++++--------- src/sandbox/mod.rs | 54 ++++++++++-- src/sandbox/ut.rs | 46 +++++++++- src/tui.rs | 14 +-- src/vscomm/mod.rs | 95 ++++++++++++++++++++- 7 files changed, 334 insertions(+), 64 deletions(-) diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 3a9d3da..a6193fa 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -8,7 +8,7 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, VSCOMM_BIN_DIR}; +use vscomm::{validate_exec_request, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, VSCOMM_BIN_DIR}; const HOST_CID: u32 = 2; @@ -38,6 +38,7 @@ fn run() -> Result<(), String> { let env_vars: Vec<(String, String)> = env::vars().collect(); let req = ExecRequest { cwd: cwd.to_string_lossy().to_string(), command: invoked_as, args: args[1..].to_vec(), env: env_vars }; + validate_exec_request(&req)?; let frame = Frame::new(FrameType::ExecReq, req.serialize()); let mut stream = vsock_connect(HOST_CID, TOOLCHAIN_PORT).map_err(|e| format!("toolchain vsock connect: {e}"))?; diff --git a/src/daemon.rs b/src/daemon.rs index 930fd0d..cbdc376 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,7 +1,7 @@ use crate::cfg::EnvMode; use crate::proxy::FilterProxy; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; -use crate::vscomm::{ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; +use crate::vscomm::{validate_exec_request, validate_process_path, validate_process_string, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; @@ -32,7 +32,7 @@ impl VsockDaemon { None } else { let loaded: Vec<_> = profiles.iter().map(|p| resolve_profile(p, &share_dir)).collect::, _>>()?; - let merged = MergedProfile::from_profiles(&loaded); + let merged = MergedProfile::from_profiles(&loaded)?; let check = std::process::Command::new("bwrap").arg("--version").output().map_err(|e| format!("bwrap not found: {e}"))?; if !check.status.success() { @@ -111,6 +111,13 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess let req = read_exec_request(&mut reader).await?; + if let Err(err) = validate_exec_request(&req) { + let msg = format!("bunkerbox-vscomm: invalid request: {err}\n"); + write_frame(&mut writer, &Frame::new(FrameType::Stderr, msg.into_bytes())).await?; + write_frame(&mut writer, &Frame::new(FrameType::Exit, 1i32.to_le_bytes().to_vec())).await?; + return Ok(()); + } + if !is_allowed(&session.passthrough, &req.command, &req.args) { let msg = format!("bunkerbox-vscomm: command '{}' not whitelisted\n", req.command); write_frame(&mut writer, &Frame::new(FrameType::Stderr, msg.into_bytes())).await?; @@ -131,6 +138,7 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess } async fn execute_request(writer: &mut W, session: &VsockSession, req: &ExecRequest) -> Result<(), String> { + validate_exec_request(req)?; let sandbox_cwd = req.cwd.clone(); let host_cwd = if req.cwd.starts_with("/workspace") { session.workspace.join(req.cwd.strip_prefix("/workspace").unwrap_or(&req.cwd).trim_start_matches('/')) @@ -144,7 +152,8 @@ async fn execute_request(writer: &mut W, session: &Vso cmd.stderr(Stdio::piped()); cmd.kill_on_drop(true); - let mut child = cmd.spawn().map_err(|e| format!("spawn {}: {e}", req.command))?; + let launcher = if session.merged_profile.is_some() { "bwrap" } else { &req.command }; + let mut child = cmd.spawn().map_err(|e| format!("spawn {launcher} for command '{}': {e}", req.command))?; let child_stdout = child.stdout.take().ok_or_else(|| "no stdout".to_string())?; let child_stderr = child.stderr.take().ok_or_else(|| "no stderr".to_string())?; @@ -185,6 +194,11 @@ async fn execute_request(writer: &mut W, session: &Vso } fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, sandbox_cwd: &str) -> Result { + validate_exec_request(req)?; + validate_process_path("workspace path", &session.workspace)?; + validate_process_path("host working directory", host_cwd)?; + validate_process_string("sandbox working directory", sandbox_cwd)?; + if let Some(ref merged) = session.merged_profile { let mut cmd = Command::new("bwrap"); diff --git a/src/main.rs b/src/main.rs index d3aafaa..bc61b23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,16 @@ use bunkerbox::cfg::{ProjectConfig, WorkspaceMode}; use bunkerbox::{cfg, cfgsetup, clidef, cmdrun, daemon, kata, logging, overlay, tui, vscomm, workspace}; -use std::cell::RefCell; use std::ffi::OsString; -use std::os::unix::io::RawFd; +use std::fs::File; +use std::io; +use std::os::fd::{FromRawFd, RawFd}; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; -use std::rc::Rc; use std::sync::{Arc, Mutex}; +const WORKSPACE_HANDOFF_MAGIC: &[u8; 4] = b"WS01"; +const MAX_WORKSPACE_HANDOFF_BYTES: usize = 64 * 1024; + fn main() { if let Err(err) = run() { eprintln!("bunkerbox: {err}"); @@ -188,17 +192,20 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option>> = Arc::new(Mutex::new(None)); let mut sock_fds = [-1i32, -1]; - unsafe { - libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sock_fds.as_mut_ptr()); + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sock_fds.as_mut_ptr()) } != 0 { + return Err(format!("status socketpair: {}", std::io::Error::last_os_error())); } let (parent_fd, child_fd) = (sock_fds[0], sock_fds[1]); + let mut setup_fds = [-1i32, -1]; + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, setup_fds.as_mut_ptr()) } != 0 { + return Err(format!("workspace setup socketpair: {}", std::io::Error::last_os_error())); + } + let (setup_parent_fd, setup_child_fd) = (setup_fds[0], setup_fds[1]); + let (cols, rows) = crossterm::terminal::size().map_err(|e| format!("terminal size: {e}"))?; let winsize = libc::winsize { ws_row: rows, ws_col: cols, ws_xpixel: 0, ws_ypixel: 0 }; @@ -212,6 +219,7 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option> = Arc::new(Mutex::new(tui::OverlayState::new())); - let overlay_clone = overlay.clone(); - - let tui_result = tui::event_loop( - master, - rows, - cols, - parent_fd, - move |path_bytes: Vec| { - let wp = PathBuf::from(String::from_utf8_lossy(&path_bytes).into_owned()); - if !passthrough.is_empty() { - *daemon_clone.borrow_mut() = Some(daemon::VsockDaemon::start(passthrough, env_mode, wp, profiles, share_dir_owned, merged_allow)?); - } + let status_listener = start_status_listener(overlay.clone())?; + + let setup_handle = tokio::runtime::Handle::current().clone(); + let daemon_slot = daemon_holder.clone(); + let setup_thread = std::thread::spawn(move || -> Result<(), String> { + let workspace = read_workspace_handoff(setup_parent_fd)?; + if passthrough.is_empty() { + return Ok(()); + } - let overlay = overlay_clone; - *status_clone.borrow_mut() = Some(start_status_listener(overlay)?); + let _guard = setup_handle.enter(); + let daemon = daemon::VsockDaemon::start(passthrough, env_mode, workspace, profiles, share_dir_owned, merged_allow)?; + *daemon_slot.lock().map_err(|_| "daemon state lock poisoned".to_string())? = Some(daemon); + Ok(()) + }); - Ok(()) - }, - overlay, - ); + let tui_result = tui::event_loop(master, rows, cols, parent_fd, overlay); let mut status: i32 = 0; unsafe { libc::waitpid(pid, &mut status, 0) }; unsafe { libc::close(master) }; - if let Some(d) = daemon_holder.borrow_mut().take() { + let setup_result = match setup_thread.join() { + Ok(result) => result, + Err(_) => Err("workspace setup thread panicked".to_string()), + }; + + if let Some(d) = daemon_holder.lock().map_err(|_| "daemon state lock poisoned".to_string())?.take() { tokio::runtime::Handle::current().block_on(d.shutdown()); } - if let Some(listener) = status_holder.borrow_mut().take() { - tokio::runtime::Handle::current().block_on(listener.shutdown()); - } + tokio::runtime::Handle::current().block_on(status_listener.shutdown()); tui_result?; + setup_result?; if status != 0 { return Err(format!("child exited with status {status}")); @@ -337,6 +340,63 @@ fn run_packaged_runtime(config: cfg::RuntimeConfig, workspace_override: Option Result<(), String> { + let bytes = path.as_os_str().as_bytes(); + let frame = encode_workspace_handoff(bytes)?; + let mut file = unsafe { File::from_raw_fd(fd) }; + io::Write::write_all(&mut file, &frame).map_err(|err| format!("write workspace handoff: {err}")) +} + +fn read_workspace_handoff(fd: RawFd) -> Result { + let mut file = unsafe { File::from_raw_fd(fd) }; + let mut header = [0u8; 8]; + io::Read::read_exact(&mut file, &mut header).map_err(|err| format!("read workspace handoff header: {err}"))?; + + let payload_len = u32::from_le_bytes([header[4], header[5], header[6], header[7]]) as usize; + if payload_len > MAX_WORKSPACE_HANDOFF_BYTES { + return Err(format!("workspace handoff is too large: {payload_len} bytes")); + } + + let mut payload = vec![0u8; payload_len]; + io::Read::read_exact(&mut file, &mut payload).map_err(|err| format!("read workspace handoff: {err}"))?; + + let mut frame = header.to_vec(); + frame.extend_from_slice(&payload); + decode_workspace_handoff(&frame) +} + +fn encode_workspace_handoff(path: &[u8]) -> Result, String> { + if path.len() > MAX_WORKSPACE_HANDOFF_BYTES { + return Err(format!("workspace handoff is too large: {} bytes", path.len())); + } + + let length = u32::try_from(path.len()).map_err(|_| "workspace handoff length overflow".to_string())?; + let mut frame = Vec::with_capacity(8 + path.len()); + frame.extend_from_slice(WORKSPACE_HANDOFF_MAGIC); + frame.extend_from_slice(&length.to_le_bytes()); + frame.extend_from_slice(path); + Ok(frame) +} + +fn decode_workspace_handoff(frame: &[u8]) -> Result { + if frame.len() < 8 { + return Err("workspace handoff is truncated".to_string()); + } + if &frame[..4] != WORKSPACE_HANDOFF_MAGIC { + return Err("workspace handoff has an invalid type".to_string()); + } + + let payload_len = u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]) as usize; + if payload_len > MAX_WORKSPACE_HANDOFF_BYTES { + return Err(format!("workspace handoff is too large: {payload_len} bytes")); + } + if frame.len() != 8 + payload_len { + return Err("workspace handoff length does not match payload".to_string()); + } + + Ok(PathBuf::from(OsString::from_vec(frame[8..].to_vec()))) +} + struct StatusListener { join_handle: tokio::task::JoinHandle<()>, shutdown: tokio::sync::oneshot::Sender<()>, @@ -419,3 +479,31 @@ fn list_sequences() -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::{decode_workspace_handoff, encode_workspace_handoff}; + use std::path::Path; + + #[test] + fn workspace_handoff_round_trips_a_path() { + let frame = encode_workspace_handoff(b"/workspace/project").unwrap(); + let path = decode_workspace_handoff(&frame).unwrap(); + + assert_eq!(path, Path::new("/workspace/project")); + } + + #[test] + fn ui_message_is_not_a_workspace_handoff() { + let ui_message = b"@popup\0info\0Bunkerbox\0Starting...\0\n"; + + assert!(decode_workspace_handoff(ui_message).is_err()); + } + + #[test] + fn workspace_handoff_rejects_truncated_payload() { + let frame = encode_workspace_handoff(b"/workspace/project").unwrap(); + + assert!(decode_workspace_handoff(&frame[..frame.len() - 1]).is_err()); + } +} diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index aa3d1a2..4296158 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use serde::Deserialize; +use crate::vscomm::{validate_env_key, validate_process_path, validate_process_string}; + #[derive(Debug, Clone, Deserialize)] pub struct Profile { pub name: String, @@ -43,38 +45,68 @@ pub struct MergedProfile { pub shell: PathBuf, } +pub fn validate_profile(profile: &Profile, source: &str) -> Result<(), String> { + validate_process_string(&format!("profile {source} name"), &profile.name)?; + + for (name, path) in &profile.bin { + validate_process_string(&format!("profile {source} binary name"), name)?; + validate_process_path(&format!("profile {source} binary '{name}' path"), path)?; + } + + for (index, path) in profile.ro.iter().enumerate() { + validate_process_string(&format!("profile {source} read-only path {index}"), path)?; + } + + for (index, path) in profile.rw.iter().enumerate() { + validate_process_string(&format!("profile {source} read-write path {index}"), path)?; + } + + for (key, value) in &profile.env { + validate_env_key(&format!("profile {source} environment key"), key)?; + validate_process_string(&format!("profile {source} environment value for '{key}'"), value)?; + } + + validate_process_path(&format!("profile {source} shell path"), &profile.shell)?; + Ok(()) +} + impl MergedProfile { - pub fn from_profiles(profiles: &[Profile]) -> Self { + pub fn from_profiles(profiles: &[Profile]) -> Result { let mut merged = MergedProfile::default(); if profiles.is_empty() { merged.name = "default".into(); merged.shell = PathBuf::from("/bin/sh"); - return merged; + return Ok(merged); } merged.name = profiles.iter().map(|p| p.name.as_str()).collect::>().join("+"); for p in profiles { + validate_profile(p, &p.name)?; for (k, v) in &p.bin { merged.bin.entry(k.clone()).or_insert_with(|| v.clone()); } for d in &p.ro { let expanded = expand_vars(d); + validate_process_string(&format!("profile {} read-only path", p.name), &expanded)?; if !merged.ro.contains(&expanded) { merged.ro.push(expanded); } } for d in &p.rw { let expanded = expand_vars(d); + validate_process_string(&format!("profile {} read-write path", p.name), &expanded)?; if !merged.rw.contains(&expanded) { merged.rw.push(expanded); } } for (k, v) in &p.env { - merged.env.entry(k.clone()).or_insert_with(|| expand_vars(v)); + let expanded = expand_vars(v); + validate_process_string(&format!("profile {} environment value for '{k}'", p.name), &expanded)?; + merged.env.entry(k.clone()).or_insert(expanded); } merged.network = p.network; merged.shell = p.shell.clone(); } - merged + Ok(merged) } } @@ -93,23 +125,29 @@ pub fn expand_vars(s: &str) -> String { } pub fn parse_profile_yaml(yaml: &str) -> Result { - serde_yaml::from_str::(yaml).map_err(|e| format!("failed to parse profile: {e}")) + parse_profile_yaml_with_source(yaml, "configuration") +} + +fn parse_profile_yaml_with_source(yaml: &str, source: &str) -> Result { + let profile = serde_yaml::from_str::(yaml).map_err(|e| format!("failed to parse profile: {e}"))?; + validate_profile(&profile, source)?; + Ok(profile) } pub fn resolve_profile(name_or_path: &str, share_dir: &std::path::Path) -> Result { if name_or_path.starts_with('/') { let contents = std::fs::read_to_string(name_or_path).map_err(|e| format!("failed to read profile {}: {e}", name_or_path))?; - return parse_profile_yaml(&contents); + return parse_profile_yaml_with_source(&contents, name_or_path); } let share_path = share_dir.join("profiles").join(format!("{name_or_path}.yaml")); if share_path.exists() { let contents = std::fs::read_to_string(&share_path).map_err(|e| format!("failed to read profile {}: {e}", share_path.display()))?; - return parse_profile_yaml(&contents); + return parse_profile_yaml_with_source(&contents, &share_path.display().to_string()); } let builtin = get_builtin_profile(name_or_path)?; - parse_profile_yaml(builtin) + parse_profile_yaml_with_source(builtin, &format!("built-in '{name_or_path}'")) } fn get_builtin_profile(name: &str) -> Result<&str, String> { diff --git a/src/sandbox/ut.rs b/src/sandbox/ut.rs index 7c602c0..985a69e 100644 --- a/src/sandbox/ut.rs +++ b/src/sandbox/ut.rs @@ -61,7 +61,7 @@ fn test_merge_profiles() { network: NetworkMode::None, shell: "/bin/dash".into(), }; - let merged = MergedProfile::from_profiles(&[p1, p2]); + let merged = MergedProfile::from_profiles(&[p1, p2]).unwrap(); assert_eq!(merged.bin.len(), 2); assert_eq!(merged.ro.len(), 2); assert_eq!(merged.rw.len(), 2); @@ -77,3 +77,47 @@ fn test_resolve_builtin() { assert!(profile.bin.contains_key("make")); assert!(profile.bin.contains_key("gcc")); } + +#[test] +fn reject_nul_in_profile_environment() { + let yaml = r#" +name: test +env: + TOOLCHAIN: "bad\0value" +"#; + + let err = parse_profile_yaml(yaml).unwrap_err(); + assert!(err.contains("environment value")); + assert!(err.contains("NUL")); +} + +#[test] +fn reject_invalid_profile_environment_key() { + let yaml = r#" +name: test +env: + BAD=KEY: value +"#; + + let err = parse_profile_yaml(yaml).unwrap_err(); + assert!(err.contains("environment key")); + assert!(err.contains("'='")); +} + +#[test] +fn reject_nul_in_profile_environment_key_without_returning_nul() { + let profile = Profile { + name: "test".into(), + bin: Default::default(), + ro: Vec::new(), + rw: Vec::new(), + env: [("BAD\0KEY".into(), "value".into())].into_iter().collect(), + network: NetworkMode::None, + shell: "/bin/sh".into(), + }; + + let err = validate_profile(&profile, "test").unwrap_err(); + assert!(err.contains("environment key")); + assert!(err.contains("NUL")); + assert!(!err.contains('\0')); +} diff --git a/src/tui.rs b/src/tui.rs index c4a9e6d..96714a9 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -441,16 +441,11 @@ fn screen_has_ascii_alphanumeric(screen: &vt100::Screen) -> bool { /// [`vt100::Parser`], and draws each frame with full 24-bit color plus a /// floating status overlay in the top-right corner. /// -/// `status_fd` is polled continuously for newline-delimited messages. The -/// first message is forwarded to `on_setup` (workspace path); subsequent -/// messages update `overlay.status_text`. +/// `status_fd` is polled continuously for newline-delimited UI messages. /// /// `overlay` is shared with the VSOCK status listener so VM-originated /// UI commands can update popups, progress bars, and status text. -pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, on_setup: F, overlay: Arc>) -> Result<(), String> -where - F: FnOnce(Vec) -> Result<(), String>, -{ +pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, overlay: Arc>) -> Result<(), String> { let stdin_fd = io::stdin().as_raw_fd(); let mut stdout = io::stdout(); @@ -468,7 +463,6 @@ where let mut last_rows = rows; let mut last_cols = cols; - let mut on_setup = Some(on_setup); let mut status_buf = Vec::new(); unsafe { @@ -552,9 +546,7 @@ where while let Some(pos) = status_buf.iter().position(|&b| b == b'\n') { let line = String::from_utf8_lossy(&status_buf[..pos]).into_owned(); status_buf.drain(..=pos); - if let Some(cb) = on_setup.take() { - cb(line.into_bytes())?; - } else if let Some(cmd) = line.strip_prefix('@') { + if let Some(cmd) = line.strip_prefix('@') { if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(cmd.as_bytes()) { let mut state = overlay.lock().unwrap(); dispatch_ui_command(&mut state, widget, cmd, opts, val); diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index e97dea2..8fc0c38 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -1,7 +1,12 @@ // Dead-code warnings are expected here: vscomm is shared between // two binaries (bunkerbox and bunkerbox-vscomm) that use different items. #![allow(dead_code)] +use std::ffi::OsStr; use std::io::{self, Read, Write}; +use std::path::Path; + +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; pub mod buildsys; pub const TOOLCHAIN_PORT: u32 = 9999; @@ -41,6 +46,65 @@ pub struct ExecRequest { pub env: Vec<(String, String)>, } +pub fn validate_process_string(field: &str, value: &str) -> Result<(), String> { + if value.as_bytes().contains(&0) { + return Err(format!("{field} contains a NUL byte")); + } + + Ok(()) +} + +pub fn validate_process_path(field: &str, value: &Path) -> Result<(), String> { + if os_str_contains_nul(value.as_os_str()) { + return Err(format!("{field} contains a NUL byte")); + } + + Ok(()) +} + +pub fn validate_env_key(field: &str, key: &str) -> Result<(), String> { + validate_process_string(field, key)?; + if key.is_empty() { + return Err(format!("{field} is empty")); + } + if key.contains('=') { + return Err(format!("{field} contains '='")); + } + + Ok(()) +} + +pub fn validate_exec_request(req: &ExecRequest) -> Result<(), String> { + validate_process_string("request cwd", &req.cwd)?; + if req.command.is_empty() { + return Err("request command is empty".to_string()); + } + validate_process_string("request command", &req.command)?; + + for (index, arg) in req.args.iter().enumerate() { + validate_process_string(&format!("request argument {index}"), arg)?; + } + + for (index, (key, value)) in req.env.iter().enumerate() { + validate_env_key(&format!("request environment key {index}"), key)?; + validate_process_string(&format!("request environment value for '{key}'"), value)?; + } + + Ok(()) +} + +fn os_str_contains_nul(value: &OsStr) -> bool { + #[cfg(unix)] + { + value.as_bytes().contains(&0) + } + + #[cfg(not(unix))] + { + value.to_string_lossy().contains('\0') + } +} + pub struct Frame { pub frame_type: FrameType, pub payload: Vec, @@ -218,11 +282,40 @@ pub fn parse_triggers(options: &str) -> Vec { #[cfg(test)] mod tests { - use super::{FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT}; + use super::{validate_exec_request, ExecRequest, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT}; #[test] fn execution_and_tui_channels_are_distinct() { assert_ne!(TOOLCHAIN_PORT, TUI_STATUS_PORT); assert_ne!(FrameType::ExecReq as u16, FrameType::UiCommand as u16); } + + #[test] + fn reject_nul_in_request_argument() { + let request = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: vec!["build\0".into()], env: Vec::new() }; + + let err = validate_exec_request(&request).unwrap_err(); + assert_eq!(err, "request argument 0 contains a NUL byte"); + } + + #[test] + fn reject_invalid_request_environment_key() { + let request = + ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: Vec::new(), env: vec![("BAD=KEY".into(), "value".into())] }; + + let err = validate_exec_request(&request).unwrap_err(); + assert_eq!(err, "request environment key 0 contains '='"); + } + + #[test] + fn accept_valid_cargo_request() { + let request = ExecRequest { + cwd: "/workspace".into(), + command: "cargo".into(), + args: vec!["build".into(), "--target-dir".into(), "target/debug".into()], + env: vec![("CARGO_TERM_COLOR".into(), "always".into())], + }; + + assert!(validate_exec_request(&request).is_ok()); + } } From 13efb14529dffd22b534dc61207279d9581bf8e8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 18:17:49 +0200 Subject: [PATCH 04/16] Update documentation on profiles --- docs/concepts.md | 19 ++++---- docs/config/project.md | 12 ++--- docs/guides/profiles.md | 85 ++++++++++++++++++--------------- docs/reference/config-schema.md | 29 +++++++++++ 4 files changed, 91 insertions(+), 54 deletions(-) diff --git a/docs/concepts.md b/docs/concepts.md index 27b33ab..10094bc 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -80,25 +80,26 @@ once it's there? That's the sandbox. When profiles are configured in `project.conf`, the host daemon does not spawn passthrough commands directly. It wraps each one inside a [bubblewrap](https://github.com/containers/bubblewrap) sandbox. Bubblewrap uses -Linux user namespaces to create a thin, unprivileged container around the -command with exactly the capabilities it needs and nothing more. +Linux namespaces to create a thin container around the command. The profile +defines the binaries, paths, environment, and network policy supplied to it. A sandbox profile is a small YAML file that declares: -- Which exact host binaries the command may access (bind-mounted read-only) -- Which directories are visible and whether they are writable +- Which exact host binaries the command may access +- Which host paths are visible and where they appear in the guest - Whether network access is permitted - Which environment variables the command inherits Bunkerbox ships profiles for common build systems — `rust`, `make`, `go`, `node`, `python` — and you can write your own. When multiple profiles are -active, they merge: the union of all binaries and directories is available to -the sandboxed command. +active, they merge: the union of all binaries and paths is available to the +sandboxed command. Inside the sandbox, the command sees a scratch `/home`, an empty `/tmp`, its -own `/proc`, no network, and only the binaries you explicitly allowed. It -cannot read your SSH keys, curl a payload, enumerate host processes, or write -anywhere outside the overlay workspace. +own `/proc`, no network, and only the binaries and paths you explicitly +allowed. Home-relative cache paths are deliberate writable carryover paths; +profile declarations are trusted host policy, not a complete rogue-process +capability model. See the [Profiles guide](guides/profiles.md) for the full reference. diff --git a/docs/config/project.md b/docs/config/project.md index e2a31a9..b15bba9 100644 --- a/docs/config/project.md +++ b/docs/config/project.md @@ -103,8 +103,8 @@ sandbox. The host daemon constructs a minimal Linux namespace from the profile rules before spawning the real command. - Only the binaries listed in the profile are visible inside the sandbox. - They are bind-mounted read-only at the paths where the command expects them. -- Filesystem access is limited to the directories the profile declares. + They are bind-mounted at the paths where the command expects them. +- Filesystem access is limited to the `paths` declared in the profile. Everything else is invisible. - The network is isolated (`--unshare-net`) unless a profile explicitly allows it. @@ -129,12 +129,12 @@ path. The format is the same as the built-ins. See the [Profiles guide](../guides/profiles.md) for the full reference. **Merging.** When multiple profiles are configured, their rules are merged. -The union of all binaries, read-only directories, writable directories, and -environment variables is available to the sandboxed command. +The union of all binaries, paths, and environment variables is available to +the sandboxed command. **When profiles are empty** (the default), passthrough commands run directly -on the host with no sandbox — the pre-bwrap legacy behavior. This is useful -when you trust the tool completely or are debugging, but it offers no +on the host with no sandbox. This is useful when you trust the tool completely +or are debugging, but it offers no additional isolation beyond the vsock whitelist. ### `image` diff --git a/docs/guides/profiles.md b/docs/guides/profiles.md index f6c004c..e45a293 100644 --- a/docs/guides/profiles.md +++ b/docs/guides/profiles.md @@ -23,29 +23,28 @@ Bunkerbox ships five profiles that cover the most common build systems. You can use them by name — no files to write, no paths to manage. **`rust`** — for projects with a `Cargo.toml`. Provides `cargo`, `rustc`, -`rustfmt`, and `cc`. Mounts `~/.cargo` and `~/.rustup` read-write so your -crates and toolchains are cached. Sets `CARGO_HOME` and `RUSTUP_HOME` so cargo -knows where to look. +`rustfmt`, and `cc`. Carries over `.cargo` and `.rustup` into the sandbox home +and sets `CARGO_HOME` and `RUSTUP_HOME` to their guest paths. **`make`** — for projects with a `Makefile`. Provides `make`, `gcc`, `g++`, `ar`, `ld`, `as`, and `strip`. No writable directories by default — make output goes to the overlay workspace. **`go`** — for projects with a `go.mod`. Provides `go` and `gofmt`. Mounts -the Go toolchain directory read-only and `~/go` read-write for the module -cache. Sets `GOROOT` and `GOPATH`. +the Go toolchain directory and carries over `go` and `.cache/go-build`. Sets +`GOROOT`, `GOPATH`, and `GOCACHE` to guest paths. **`node`** — for projects with a `package.json`. Provides `node`, `npm`, and -`npx`. Mounts `~/.npm` and `~/.node-gyp` read-write so packages are cached -between runs. +`npx`. Carries over `.npm` and `.node-gyp` so packages are cached between +runs. **`python`** — for projects with `pyproject.toml` or `setup.py`. Provides -`python3` and `pip3` (also aliased as `python` and `pip`). Mounts the pip -cache read-write. +`python3` and `pip3` (also aliased as `python` and `pip`). Carries over the +`.cache/pip` cache. All profiles share the same base rules: system libraries (`/lib`, `/lib64`, -`/usr/lib`) are mounted read-only, the network is disabled, and the shell is -`/bin/sh`. +`/usr/lib`) are available at their standard paths, the network is disabled, +and the shell is `/bin/sh`. ## Using profiles @@ -66,7 +65,7 @@ both Cargo and Make, add both `rust` and `make`. The sandboxed command will have access to the union of all binaries and directories from both profiles. If `profiles` is empty or absent, passthrough commands run directly on the -host with no sandbox — the pre-bwrap legacy behavior. +host with no sandbox. ## Custom profiles @@ -81,14 +80,12 @@ bin: my-compiler: /opt/toolchain/bin/my-compiler my-linker: /opt/toolchain/bin/my-linker -ro: - - /lib - - /lib64 - - /usr/lib - - /opt/toolchain/lib - -rw: - - "${HOME}/.cache/my-toolchain" +paths: + - src: /lib + - src: /lib64 + - src: /usr/lib + - src: /opt/toolchain/lib + - src: .cache/my-toolchain env: TOOLCHAIN_HOME: /opt/toolchain @@ -115,18 +112,27 @@ resolves the path against your `PATH` before mounting, so you can write `cargo: /usr/bin/cargo` and it will still work if cargo lives at `~/.cargo/bin/cargo` — the daemon finds it for you. -**`ro`** — directories mounted read-only inside the sandbox. Use these -for system libraries, toolchain directories, SSL certificates, timezone data, -and anything else the tools need to read but should never modify. +**`paths`** — host paths made available inside the sandbox. A relative `src` +is resolved below the host user's home and appears below `/home` in the guest: + +```yaml +paths: + - src: .cargo + - src: /usr/lib/some/cpp/includes/crap + - src: /opt/sdk/include + dst: /toolchain/include +``` -**`rw`** — directories mounted read-write inside the sandbox. Use these -for caches, build artifacts that should persist between runs, and any -directory the toolchain needs to write to. The `${HOME}` variable expands to -your host home directory. +Relative paths are writable carryover data and use `/home/` as the guest +destination. Absolute paths below the host home use the corresponding `/home` +destination. Absolute paths outside the host home keep the same destination and +are mounted as system/toolchain inputs. An explicit `dst` overrides the +destination. The profile author is responsible for the host paths selected. -**`env`** — environment variables set inside the sandbox. Use these for -toolchain configuration (`CARGO_HOME`, `GOPATH`, etc.). `${HOME}`, `${USER}`, -and `${TERM}` are expanded automatically. +**`env`** — guest environment variables set inside the sandbox. Use guest paths +such as `/home/.cargo` for toolchain configuration (`CARGO_HOME`, `GOPATH`, +etc.). `${HOME}` expands to `/home`; `${USER}` and `${TERM}` use the host +runtime values when present. **`network`** — currently only `none` is supported. The sandboxed command has no network access. @@ -136,17 +142,17 @@ no network access. ## How rules translate to isolation -When a profile is active, each binary in the list is bind-mounted read-only -at its expected path inside the sandbox. If the binary is a symlink (common +When a profile is active, each binary in the list is bind-mounted at its +expected path inside the sandbox. If the binary is a symlink (common with rustup, where `cargo` and `rustc` both point to the same `rustup` binary), the daemon follows the link and mounts the real file — so the sandbox sees a working executable, not a dangling symlink. -Read-only directories are mounted recursively, so `/usr/lib` brings in the -full tree. Writeable directories are plain bind mounts — changes inside the -sandbox are visible on the host. The overlay workspace at `.bunkerbox/workspace` -is always mounted read-write at `/workspace` inside the sandbox, so build -output always lands in the copy-on-write layer. +Absolute system and toolchain paths are mounted at their standard destinations. +Home-relative paths are plain writable carryover binds, so changes are visible +on the host. The overlay workspace at `.bunkerbox/workspace` is always mounted +read-write at `/workspace` inside the sandbox, so build output always lands in +the copy-on-write layer. The command gets a clean environment: no host variables leak in, and the profile's `env` block provides exactly what the toolchain needs. `/proc` and @@ -154,5 +160,6 @@ profile's `env` block provides exactly what the toolchain needs. `/proc` and devices. `/tmp` and `/home` are empty tmpfs mounts, discarded when the command exits. -All of this is enforced by bubblewrap using unprivileged user namespaces — no -root, no setuid, no kernel modules. +Bubblewrap provides the namespace boundary. Host paths selected by a profile +remain trusted profile policy; path declarations are not a substitute for +capability dropping or a non-root threat model. diff --git a/docs/reference/config-schema.md b/docs/reference/config-schema.md index 89ab802..462d8c3 100644 --- a/docs/reference/config-schema.md +++ b/docs/reference/config-schema.md @@ -99,6 +99,35 @@ project: # - extra.api.example.com ``` +## Sandbox profile + +Profiles are host-side YAML files selected by the project configuration. + +```yaml +name: rust +bin: + cargo: /usr/bin/cargo +paths: + - src: /lib + - src: /usr/lib + - src: .cargo + - src: .rustup + - src: /opt/sdk/include + dst: /toolchain/include +env: + CARGO_HOME: /home/.cargo + RUSTUP_HOME: /home/.rustup +network: none +shell: /bin/sh +``` + +Relative `src` paths are resolved below the host user's home and appear below +`/home` in the guest. Absolute paths below the host home use the corresponding +`/home` destination. Absolute paths outside the host home retain their source +path as the guest destination unless `dst` is supplied. Home-relative paths are +writable carryover data; absolute system and toolchain paths are read-only +inputs by default. These declarations are trusted host policy. + During development, runtime configs live in `runtime/`. In a packaged install, they live under: ```text From d0f3abf89e5301409a950f2446d37c8e6b069f90 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 18:18:00 +0200 Subject: [PATCH 05/16] Update general profiles --- profiles/go.yaml | 29 ++++++++++++++--------------- profiles/make.yaml | 20 +++++++++----------- profiles/node.yaml | 26 ++++++++++++-------------- profiles/python.yaml | 26 ++++++++++++-------------- profiles/rust.yaml | 28 +++++++++++++--------------- 5 files changed, 60 insertions(+), 69 deletions(-) diff --git a/profiles/go.yaml b/profiles/go.yaml index 18d5d93..153abd4 100644 --- a/profiles/go.yaml +++ b/profiles/go.yaml @@ -4,24 +4,23 @@ bin: go: /usr/local/go/bin/go gofmt: /usr/local/go/bin/gofmt -ro: - - /lib - - /lib64 - - /usr/lib - - /usr/lib64 - - /etc/ssl - - /etc/localtime - - /usr/share/zoneinfo - - /usr/lib/locale - - /usr/local/go - -rw: - - "${HOME}/go" - - "${HOME}/.cache/go-build" +paths: + - src: /lib + - src: /lib64 + - src: /usr/lib + - src: /usr/lib64 + - src: /etc/ssl + - src: /etc/localtime + - src: /usr/share/zoneinfo + - src: /usr/lib/locale + - src: /usr/local/go + - src: go + - src: .cache/go-build env: GOROOT: /usr/local/go - GOPATH: "${HOME}/go" + GOPATH: /home/go + GOCACHE: /home/.cache/go-build TERM: "${TERM}" network: none diff --git a/profiles/make.yaml b/profiles/make.yaml index e4ad633..de2cdc3 100644 --- a/profiles/make.yaml +++ b/profiles/make.yaml @@ -11,17 +11,15 @@ bin: as: /usr/bin/as strip: /usr/bin/strip -ro: - - /lib - - /lib64 - - /usr/lib - - /usr/lib64 - - /etc/ssl - - /etc/localtime - - /usr/share/zoneinfo - - /usr/lib/locale - -rw: [] +paths: + - src: /lib + - src: /lib64 + - src: /usr/lib + - src: /usr/lib64 + - src: /etc/ssl + - src: /etc/localtime + - src: /usr/share/zoneinfo + - src: /usr/lib/locale env: TERM: "${TERM}" diff --git a/profiles/node.yaml b/profiles/node.yaml index 7ab87e1..b23abf3 100644 --- a/profiles/node.yaml +++ b/profiles/node.yaml @@ -5,22 +5,20 @@ bin: npm: /usr/bin/npm npx: /usr/bin/npx -ro: - - /lib - - /lib64 - - /usr/lib - - /usr/lib64 - - /etc/ssl - - /etc/localtime - - /usr/share/zoneinfo - - /usr/lib/locale - -rw: - - "${HOME}/.npm" - - "${HOME}/.node-gyp" +paths: + - src: /lib + - src: /lib64 + - src: /usr/lib + - src: /usr/lib64 + - src: /etc/ssl + - src: /etc/localtime + - src: /usr/share/zoneinfo + - src: /usr/lib/locale + - src: .npm + - src: .node-gyp env: - npm_config_cache: "${HOME}/.npm" + npm_config_cache: /home/.npm TERM: "${TERM}" network: none diff --git a/profiles/python.yaml b/profiles/python.yaml index 573261b..7c1f04a 100644 --- a/profiles/python.yaml +++ b/profiles/python.yaml @@ -6,22 +6,20 @@ bin: pip3: /usr/bin/pip3 pip: /usr/bin/pip3 -ro: - - /lib - - /lib64 - - /usr/lib - - /usr/lib64 - - /etc/ssl - - /etc/localtime - - /usr/share/zoneinfo - - /usr/lib/locale - - /usr/share/python3 - -rw: - - "${HOME}/.cache/pip" +paths: + - src: /lib + - src: /lib64 + - src: /usr/lib + - src: /usr/lib64 + - src: /etc/ssl + - src: /etc/localtime + - src: /usr/share/zoneinfo + - src: /usr/lib/locale + - src: /usr/share/python3 + - src: .cache/pip env: - PIP_CACHE_DIR: "${HOME}/.cache/pip" + PIP_CACHE_DIR: /home/.cache/pip TERM: "${TERM}" network: none diff --git a/profiles/rust.yaml b/profiles/rust.yaml index 83fe89d..434dafd 100644 --- a/profiles/rust.yaml +++ b/profiles/rust.yaml @@ -7,23 +7,21 @@ bin: rustup: /usr/bin/rustup cc: /usr/bin/cc -ro: - - /lib - - /lib64 - - /usr/lib - - /usr/lib64 - - /etc/ssl - - /etc/localtime - - /usr/share/zoneinfo - - /usr/lib/locale - -rw: - - "${HOME}/.cargo" - - "${HOME}/.rustup" +paths: + - src: /lib + - src: /lib64 + - src: /usr/lib + - src: /usr/lib64 + - src: /etc/ssl + - src: /etc/localtime + - src: /usr/share/zoneinfo + - src: /usr/lib/locale + - src: .cargo + - src: .rustup env: - CARGO_HOME: "${HOME}/.cargo" - RUSTUP_HOME: "${HOME}/.rustup" + CARGO_HOME: /home/.cargo + RUSTUP_HOME: /home/.rustup TERM: "${TERM}" network: none From fe8676548ede60cecd4f590580ecbcb2e4c5fc11 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 18:18:34 +0200 Subject: [PATCH 06/16] Setup bubble wrap paths according to the profiles --- src/daemon.rs | 23 ++++++------ src/sandbox/mod.rs | 94 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 76 insertions(+), 41 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index cbdc376..c64e03d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -32,7 +32,8 @@ impl VsockDaemon { None } else { let loaded: Vec<_> = profiles.iter().map(|p| resolve_profile(p, &share_dir)).collect::, _>>()?; - let merged = MergedProfile::from_profiles(&loaded)?; + let host_home = std::env::var_os("HOME").map(PathBuf::from); + let merged = MergedProfile::from_profiles(&loaded, host_home.as_deref())?; let check = std::process::Command::new("bwrap").arg("--version").output().map_err(|e| format!("bwrap not found: {e}"))?; if !check.status.success() { @@ -217,17 +218,16 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san cmd.arg("--ro-bind").arg(&resolved).arg(&dest); } - for dir in &merged.ro { - let p = Path::new(dir); - if p.exists() { - cmd.arg("--ro-bind").arg(dir).arg(dir); + cmd.arg("--tmpfs").arg("/home"); + for path in &merged.paths { + if !path.source.exists() { + eprintln!("bunkerbox: warning: profile path '{}' not found, skipping", path.source.display()); + continue; } - } - - for dir in &merged.rw { - let p = Path::new(dir); - if p.exists() { - cmd.arg("--bind").arg(dir).arg(dir); + if path.writable { + cmd.arg("--bind").arg(&path.source).arg(&path.destination); + } else { + cmd.arg("--ro-bind").arg(&path.source).arg(&path.destination); } } @@ -247,7 +247,6 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san cmd.arg("--proc").arg("/proc"); cmd.arg("--dev").arg("/dev"); cmd.arg("--tmpfs").arg("/tmp"); - cmd.arg("--tmpfs").arg("/home"); if !sandbox_cwd.is_empty() && sandbox_cwd != "/" { cmd.arg("--dir").arg(sandbox_cwd); diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 4296158..088b747 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -1,20 +1,19 @@ use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; use serde::Deserialize; use crate::vscomm::{validate_env_key, validate_process_path, validate_process_string}; #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Profile { pub name: String, #[serde(default)] #[serde(alias = "binaries")] pub bin: BTreeMap, - #[serde(default, alias = "ro_dirs")] - pub ro: Vec, - #[serde(default, alias = "rw_dirs")] - pub rw: Vec, + #[serde(default)] + pub paths: Vec, #[serde(default)] pub env: BTreeMap, #[serde(default)] @@ -23,6 +22,14 @@ pub struct Profile { pub shell: PathBuf, } +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfilePath { + pub src: PathBuf, + #[serde(default)] + pub dst: Option, +} + fn default_shell() -> PathBuf { PathBuf::from("/bin/sh") } @@ -38,13 +45,19 @@ pub enum NetworkMode { pub struct MergedProfile { pub name: String, pub bin: BTreeMap, - pub ro: Vec, - pub rw: Vec, + pub paths: Vec, pub env: BTreeMap, pub network: NetworkMode, pub shell: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPath { + pub source: PathBuf, + pub destination: PathBuf, + pub writable: bool, +} + pub fn validate_profile(profile: &Profile, source: &str) -> Result<(), String> { validate_process_string(&format!("profile {source} name"), &profile.name)?; @@ -53,12 +66,20 @@ pub fn validate_profile(profile: &Profile, source: &str) -> Result<(), String> { validate_process_path(&format!("profile {source} binary '{name}' path"), path)?; } - for (index, path) in profile.ro.iter().enumerate() { - validate_process_string(&format!("profile {source} read-only path {index}"), path)?; - } - - for (index, path) in profile.rw.iter().enumerate() { - validate_process_string(&format!("profile {source} read-write path {index}"), path)?; + for (index, path) in profile.paths.iter().enumerate() { + validate_process_path(&format!("profile {source} path {index} source"), &path.src)?; + if let Some(destination) = &path.dst { + validate_process_path(&format!("profile {source} path {index} destination"), destination)?; + if !destination.is_absolute() { + return Err(format!("profile {source} path {index} destination must be absolute")); + } + } + if path.src.as_os_str().is_empty() { + return Err(format!("profile {source} path {index} source is empty")); + } + if path.src.components().any(|component| matches!(component, Component::ParentDir)) { + return Err(format!("profile {source} path {index} source contains '..'")); + } } for (key, value) in &profile.env { @@ -71,7 +92,7 @@ pub fn validate_profile(profile: &Profile, source: &str) -> Result<(), String> { } impl MergedProfile { - pub fn from_profiles(profiles: &[Profile]) -> Result { + pub fn from_profiles(profiles: &[Profile], host_home: Option<&Path>) -> Result { let mut merged = MergedProfile::default(); if profiles.is_empty() { merged.name = "default".into(); @@ -84,18 +105,10 @@ impl MergedProfile { for (k, v) in &p.bin { merged.bin.entry(k.clone()).or_insert_with(|| v.clone()); } - for d in &p.ro { - let expanded = expand_vars(d); - validate_process_string(&format!("profile {} read-only path", p.name), &expanded)?; - if !merged.ro.contains(&expanded) { - merged.ro.push(expanded); - } - } - for d in &p.rw { - let expanded = expand_vars(d); - validate_process_string(&format!("profile {} read-write path", p.name), &expanded)?; - if !merged.rw.contains(&expanded) { - merged.rw.push(expanded); + for (index, path) in p.paths.iter().enumerate() { + let resolved = resolve_path(path, host_home).map_err(|err| format!("profile {} path {index}: {err}", p.name))?; + if !merged.paths.contains(&resolved) { + merged.paths.push(resolved); } } for (k, v) in &p.env { @@ -112,9 +125,7 @@ impl MergedProfile { pub fn expand_vars(s: &str) -> String { let mut result = s.to_string(); - if let Ok(home) = std::env::var("HOME") { - result = result.replace("${HOME}", &home); - } + result = result.replace("${HOME}", "/home"); if let Ok(user) = std::env::var("USER") { result = result.replace("${USER}", &user); } @@ -124,6 +135,31 @@ pub fn expand_vars(s: &str) -> String { result } +fn resolve_path(path: &ProfilePath, host_home: Option<&Path>) -> Result { + let (source, relative_to_home, writable) = if path.src.is_absolute() { + let source = path.src.clone(); + let relative = match host_home { + Some(home) => source.strip_prefix(home).ok().filter(|relative| !relative.as_os_str().is_empty()).map(PathBuf::from), + None => None, + }; + let writable = relative.is_some(); + (source, relative, writable) + } else { + let home = host_home.ok_or_else(|| "relative source requires host HOME".to_string())?; + (home.join(&path.src), Some(path.src.clone()), true) + }; + + let destination = if let Some(destination) = &path.dst { + destination.clone() + } else if let Some(relative) = relative_to_home { + Path::new("/home").join(relative) + } else { + source.clone() + }; + + Ok(ResolvedPath { source, destination, writable }) +} + pub fn parse_profile_yaml(yaml: &str) -> Result { parse_profile_yaml_with_source(yaml, "configuration") } From 59c788f04e149b293124992fb7271cf94b6ae46a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 18:18:40 +0200 Subject: [PATCH 07/16] Add unit tests --- src/sandbox/ut.rs | 65 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/src/sandbox/ut.rs b/src/sandbox/ut.rs index 985a69e..2c2b803 100644 --- a/src/sandbox/ut.rs +++ b/src/sandbox/ut.rs @@ -1,6 +1,6 @@ use super::*; use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[test] fn test_parse_profile() { @@ -8,10 +8,9 @@ fn test_parse_profile() { name: test bin: ls: /usr/bin/ls -ro: - - /lib -rw: - - "${HOME}/.cache" +paths: + - src: /lib + - src: .cache env: FOO: bar network: none @@ -20,8 +19,7 @@ shell: /bin/sh let profile = parse_profile_yaml(yaml).unwrap(); assert_eq!(profile.name, "test"); assert_eq!(profile.bin.get("ls").unwrap(), &std::path::PathBuf::from("/usr/bin/ls")); - assert_eq!(profile.ro.len(), 1); - assert_eq!(profile.rw.len(), 1); + assert_eq!(profile.paths.len(), 2); assert!(matches!(profile.network, NetworkMode::None)); } @@ -34,8 +32,7 @@ fn test_merge_profiles() { m.insert("cmd1".into(), "/usr/bin/cmd1".into()); m }, - ro: vec!["/lib".into()], - rw: vec!["/cache".into()], + paths: vec![ProfilePath { src: "/lib".into(), dst: None }, ProfilePath { src: "cache".into(), dst: None }], env: { let mut m = BTreeMap::new(); m.insert("A".into(), "1".into()); @@ -51,8 +48,7 @@ fn test_merge_profiles() { m.insert("cmd2".into(), "/usr/bin/cmd2".into()); m }, - ro: vec!["/usr/lib".into()], - rw: vec!["/other".into()], + paths: vec![ProfilePath { src: "/usr/lib".into(), dst: None }, ProfilePath { src: "other".into(), dst: None }], env: { let mut m = BTreeMap::new(); m.insert("B".into(), "2".into()); @@ -61,10 +57,11 @@ fn test_merge_profiles() { network: NetworkMode::None, shell: "/bin/dash".into(), }; - let merged = MergedProfile::from_profiles(&[p1, p2]).unwrap(); + let merged = MergedProfile::from_profiles(&[p1, p2], Some(Path::new("/home/test"))).unwrap(); assert_eq!(merged.bin.len(), 2); - assert_eq!(merged.ro.len(), 2); - assert_eq!(merged.rw.len(), 2); + assert_eq!(merged.paths.len(), 4); + assert!(!merged.paths[0].writable); + assert_eq!(merged.paths[1].destination, PathBuf::from("/home/cache")); assert_eq!(merged.env.len(), 2); assert_eq!(merged.shell, PathBuf::from("/bin/dash")); assert_eq!(merged.name, "a+b"); @@ -109,8 +106,7 @@ fn reject_nul_in_profile_environment_key_without_returning_nul() { let profile = Profile { name: "test".into(), bin: Default::default(), - ro: Vec::new(), - rw: Vec::new(), + paths: Vec::new(), env: [("BAD\0KEY".into(), "value".into())].into_iter().collect(), network: NetworkMode::None, shell: "/bin/sh".into(), @@ -121,3 +117,40 @@ fn reject_nul_in_profile_environment_key_without_returning_nul() { assert!(err.contains("NUL")); assert!(!err.contains('\0')); } + +#[test] +fn resolve_paths_with_standard_and_explicit_destinations() { + let profile = Profile { + name: "paths".into(), + bin: Default::default(), + paths: vec![ + ProfilePath { src: ".cargo".into(), dst: None }, + ProfilePath { src: "/home/bo/.rustup".into(), dst: None }, + ProfilePath { src: "/opt/sdk/include".into(), dst: None }, + ProfilePath { src: "/opt/sdk/lib".into(), dst: Some("/toolchain/lib".into()) }, + ], + env: Default::default(), + network: NetworkMode::None, + shell: "/bin/sh".into(), + }; + + let merged = MergedProfile::from_profiles(&[profile], Some(Path::new("/home/bo"))).unwrap(); + + assert_eq!(merged.paths[0].source, PathBuf::from("/home/bo/.cargo")); + assert_eq!(merged.paths[0].destination, PathBuf::from("/home/.cargo")); + assert!(merged.paths[0].writable); + assert_eq!(merged.paths[1].destination, PathBuf::from("/home/.rustup")); + assert!(merged.paths[1].writable); + assert_eq!(merged.paths[2].destination, PathBuf::from("/opt/sdk/include")); + assert!(!merged.paths[2].writable); + assert_eq!(merged.paths[3].destination, PathBuf::from("/toolchain/lib")); + assert!(!merged.paths[3].writable); +} + +#[test] +fn all_builtin_profiles_use_paths() { + for name in ["rust", "make", "node", "go", "python"] { + let profile = resolve_profile(name, Path::new("/nonexistent")).unwrap(); + assert!(!profile.paths.is_empty(), "{name} has no paths"); + } +} From 73ffd9bf516c966bff964a6652f2ad2d5c887b5e Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 18:51:43 +0200 Subject: [PATCH 08/16] Refactor unit tests --- src/bin/bunkerbox-vscomm.rs | 40 +++++++++++---------- src/bunkerbox-vscomm_ut.rs | 12 +++++++ src/clidef.rs | 2 +- src/daemon.rs | 45 ++++++------------------ src/logging.rs | 41 ++++++++++++++++++--- src/logging_ut.rs | 18 ++++++++++ src/main.rs | 28 ++------------- src/main_ut.rs | 24 +++++++++++++ src/proxy.rs | 8 +++-- src/sandbox/mod.rs | 2 +- src/sandbox/{ut.rs => mod_ut.rs} | 0 src/vscomm/buildsys/mod.rs | 2 +- src/vscomm/buildsys/{ut.rs => mod_ut.rs} | 0 src/vscomm/mod.rs | 40 ++------------------- src/vscomm/mod_ut.rs | 35 ++++++++++++++++++ 15 files changed, 169 insertions(+), 128 deletions(-) create mode 100644 src/bunkerbox-vscomm_ut.rs create mode 100644 src/logging_ut.rs create mode 100644 src/main_ut.rs rename src/sandbox/{ut.rs => mod_ut.rs} (100%) rename src/vscomm/buildsys/{ut.rs => mod_ut.rs} (100%) create mode 100644 src/vscomm/mod_ut.rs diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index a6193fa..fd6fdf8 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -13,9 +13,7 @@ use vscomm::{validate_exec_request, ExecRequest, Frame, FrameType, TOOLCHAIN_POR const HOST_CID: u32 = 2; fn main() { - let result = run(); - if let Err(err) = result { - eprintln!("bunkerbox-vscomm: {err}"); + if run().is_err() { std::process::exit(1); } } @@ -49,24 +47,24 @@ fn run() -> Result<(), String> { loop { let response = Frame::read(&mut stream).map_err(|e| format!("read response: {e}"))?; - match response.frame_type { - FrameType::Stdout => { - io::stdout().write_all(&response.payload).map_err(|e| format!("stdout: {e}"))?; - io::stdout().flush().map_err(|e| format!("flush stdout: {e}"))?; - } - FrameType::Stderr => { - io::stderr().write_all(&response.payload).map_err(|e| format!("stderr: {e}"))?; - io::stderr().flush().map_err(|e| format!("flush stderr: {e}"))?; - } - FrameType::Exit => { - if response.payload.len() >= 4 { - let code = i32::from_le_bytes([response.payload[0], response.payload[1], response.payload[2], response.payload[3]]); - std::process::exit(code); - } - return Ok(()); + if let Some(code) = handle_response(response)? { + std::process::exit(code); + } + } +} + +fn handle_response(response: Frame) -> Result, String> { + match response.frame_type { + FrameType::Stdout | FrameType::Stderr => Ok(None), + FrameType::Exit => { + if response.payload.len() >= 4 { + let code = i32::from_le_bytes([response.payload[0], response.payload[1], response.payload[2], response.payload[3]]); + Ok(Some(code)) + } else { + Ok(Some(0)) } - _ => return Err(format!("unexpected frame type from host: {:?}", response.frame_type as u16)), } + _ => Err(format!("unexpected frame type from host: {:?}", response.frame_type as u16)), } } @@ -229,3 +227,7 @@ impl Drop for VsockStream { unsafe { libc::close(self.fd) }; } } + +#[cfg(test)] +#[path = "../bunkerbox-vscomm_ut.rs"] +mod vscomm_tests; diff --git a/src/bunkerbox-vscomm_ut.rs b/src/bunkerbox-vscomm_ut.rs new file mode 100644 index 0000000..f1201e5 --- /dev/null +++ b/src/bunkerbox-vscomm_ut.rs @@ -0,0 +1,12 @@ +use super::{handle_response, Frame, FrameType}; + +#[test] +fn discard_stdout_and_stderr_frames() { + assert_eq!(handle_response(Frame::new(FrameType::Stdout, b"visible output".to_vec())).unwrap(), None); + assert_eq!(handle_response(Frame::new(FrameType::Stderr, b"/home/bo/Pictures/Screenshots/path.png".to_vec())).unwrap(), None); +} + +#[test] +fn preserve_exit_status() { + assert_eq!(handle_response(Frame::new(FrameType::Exit, (-17i32).to_le_bytes().to_vec())).unwrap(), Some(-17)); +} diff --git a/src/clidef.rs b/src/clidef.rs index b453d84..60973dc 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -48,7 +48,7 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("share").long("share").help("Override bunkerbox share directory")) .arg(Arg::new("workspace").long("workspace").value_parser(["share", "clone"]).help("Override workspace mode: share or clone")) .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue).help("Print log messages to stderr")) - .arg(Arg::new("log").long("log").value_name("PATH").help("Write log messages to the given file")) + .arg(Arg::new("log").long("log").value_name("PATH").help("Write diagnostics and proxied command output to the given file")) .arg(help_arg()) .arg(Arg::new("version").short('v').long("version").action(ArgAction::SetTrue).help("Get the current version.")) .disable_help_flag(true) diff --git a/src/daemon.rs b/src/daemon.rs index c64e03d..732e724 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,4 +1,5 @@ use crate::cfg::EnvMode; +use crate::logging; use crate::proxy::FilterProxy; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; use crate::vscomm::{validate_exec_request, validate_process_path, validate_process_string, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; @@ -62,7 +63,7 @@ impl VsockDaemon { let join_handle = tokio::spawn(async move { let result = daemon_loop(session, listener, shutdown_rx).await; if let Err(err) = result { - eprintln!("bunkerbox: vsock daemon: {err}"); + logging::diagnostic(&format!("bunkerbox: vsock daemon: {err}")); } }); @@ -89,12 +90,12 @@ async fn daemon_loop( let session = session.clone(); tokio::spawn(async move { if let Err(err) = handle_connection(stream, &session).await { - eprintln!("bunkerbox: toolchain vsock session failed: {err}"); + logging::diagnostic(&format!("bunkerbox: toolchain vsock session failed: {err}")); } }); } Err(e) => { - eprintln!("bunkerbox: vsock accept error: {e}"); + logging::diagnostic(&format!("bunkerbox: vsock accept error: {e}")); } } } @@ -128,7 +129,7 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess let command = req.command.clone(); if let Err(err) = execute_request(&mut writer, session, &req).await { - eprintln!("bunkerbox: toolchain command '{command}' failed: {err}"); + logging::diagnostic(&format!("bunkerbox: toolchain command '{command}' failed: {err}")); let msg = format!("bunkerbox-vscomm: {err}\n"); let _ = write_frame(&mut writer, &Frame::new(FrameType::Stderr, msg.into_bytes())).await; let _ = write_frame(&mut writer, &Frame::new(FrameType::Exit, 1i32.to_le_bytes().to_vec())).await; @@ -159,30 +160,8 @@ async fn execute_request(writer: &mut W, session: &Vso let child_stdout = child.stdout.take().ok_or_else(|| "no stdout".to_string())?; let child_stderr = child.stderr.take().ok_or_else(|| "no stderr".to_string())?; - let (stdout_tx, mut stdout_rx) = tokio::sync::mpsc::unbounded_channel::>(); - let (stderr_tx, mut stderr_rx) = tokio::sync::mpsc::unbounded_channel::>(); - - let stdout_task = tokio::spawn(async move { pump_to_channel(child_stdout, stdout_tx).await }); - let stderr_task = tokio::spawn(async move { pump_to_channel(child_stderr, stderr_tx).await }); - - loop { - tokio::select! { - chunk = stdout_rx.recv() => { - if let Some(data) = chunk { - write_frame(writer, &Frame::new(FrameType::Stdout, data)).await?; - } - } - chunk = stderr_rx.recv() => { - if let Some(data) = chunk { - write_frame(writer, &Frame::new(FrameType::Stderr, data)).await?; - } - } - } - - if stdout_rx.is_closed() && stderr_rx.is_closed() { - break; - } - } + let stdout_task = tokio::spawn(async move { pump_to_log(child_stdout, "stdout").await }); + let stderr_task = tokio::spawn(async move { pump_to_log(child_stderr, "stderr").await }); let status = child.wait().await.map_err(|e| format!("wait {}: {e}", req.command))?; let exit_code = status.code().unwrap_or(-1); @@ -211,7 +190,7 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san } else if let Some(found) = find_in_path(name) { found } else { - eprintln!("bunkerbox: warning: binary '{name}' not found, skipping"); + logging::diagnostic(&format!("bunkerbox: warning: binary '{name}' not found, skipping")); continue; }; let dest = PathBuf::from("/usr/bin").join(name); @@ -221,7 +200,7 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san cmd.arg("--tmpfs").arg("/home"); for path in &merged.paths { if !path.source.exists() { - eprintln!("bunkerbox: warning: profile path '{}' not found, skipping", path.source.display()); + logging::diagnostic(&format!("bunkerbox: warning: profile path '{}' not found, skipping", path.source.display())); continue; } if path.writable { @@ -379,15 +358,13 @@ async fn read_exec_request(reader: &mut R) -> Result(mut reader: R, tx: tokio::sync::mpsc::UnboundedSender>) { +async fn pump_to_log(mut reader: R, stream: &'static str) { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { - if tx.send(buf[..n].to_vec()).is_err() { - break; - } + logging::diagnostic_bytes(stream, &buf[..n]); } Err(_) => break, } diff --git a/src/logging.rs b/src/logging.rs index 0c2fd77..868b6f4 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -21,6 +21,37 @@ pub fn log_path() -> String { LOG_FILE.lock().unwrap().clone().unwrap_or_else(|| "/tmp/bunkerbox.log".to_string()) } +fn write_log(bytes: &[u8]) { + let path = LOG_FILE.lock().unwrap().clone(); + if let Some(path) = path { + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { + let _ = f.write_all(bytes); + } + } +} + +/// Writes a diagnostic to the configured log without sending it to the terminal or TUI. +pub fn diagnostic(msg: &str) { + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + let line = format!("[{ts}] {msg}\n"); + write_log(line.as_bytes()); +} + +/// Writes captured command output to the configured log, or discards it when logging is disabled. +pub fn diagnostic_bytes(stream: &str, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); + let mut line = format!("[{ts}] {stream}: ").into_bytes(); + line.extend_from_slice(bytes); + if !line.ends_with(b"\n") { + line.push(b'\n'); + } + write_log(&line); +} + pub fn set_status_fd(fd: RawFd) { STATUS_FD.with(|f| *f.borrow_mut() = Some(fd)); } @@ -69,11 +100,7 @@ pub fn log(msg: &str) { eprint!("[bb] {line}"); } - if let Some(ref path) = *LOG_FILE.lock().unwrap() { - if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { - let _ = f.write_all(line.as_bytes()); - } - } + write_log(line.as_bytes()); STATUS_FD.with(|f| { if let Some(fd) = *f.borrow() { @@ -90,3 +117,7 @@ pub fn log(msg: &str) { } }); } + +#[cfg(test)] +#[path = "logging_ut.rs"] +mod logging_tests; diff --git a/src/logging_ut.rs b/src/logging_ut.rs new file mode 100644 index 0000000..e216451 --- /dev/null +++ b/src/logging_ut.rs @@ -0,0 +1,18 @@ +use super::{configure, diagnostic, diagnostic_bytes}; +use std::fs; + +#[test] +fn diagnostics_write_to_file_without_terminal_output() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("bunkerbox.log"); + configure(false, Some(path.to_string_lossy().into_owned())); + + diagnostic("daemon warning"); + diagnostic_bytes("stderr", b"/home/bo/Pictures/Screenshots/path.png"); + + let contents = fs::read_to_string(&path).unwrap(); + assert!(contents.contains("daemon warning")); + assert!(contents.contains("stderr: /home/bo/Pictures/Screenshots/path.png")); + + configure(false, None); +} diff --git a/src/main.rs b/src/main.rs index bc61b23..f2f6a38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -481,29 +481,5 @@ fn list_sequences() -> Result<(), String> { } #[cfg(test)] -mod tests { - use super::{decode_workspace_handoff, encode_workspace_handoff}; - use std::path::Path; - - #[test] - fn workspace_handoff_round_trips_a_path() { - let frame = encode_workspace_handoff(b"/workspace/project").unwrap(); - let path = decode_workspace_handoff(&frame).unwrap(); - - assert_eq!(path, Path::new("/workspace/project")); - } - - #[test] - fn ui_message_is_not_a_workspace_handoff() { - let ui_message = b"@popup\0info\0Bunkerbox\0Starting...\0\n"; - - assert!(decode_workspace_handoff(ui_message).is_err()); - } - - #[test] - fn workspace_handoff_rejects_truncated_payload() { - let frame = encode_workspace_handoff(b"/workspace/project").unwrap(); - - assert!(decode_workspace_handoff(&frame[..frame.len() - 1]).is_err()); - } -} +#[path = "main_ut.rs"] +mod main_tests; diff --git a/src/main_ut.rs b/src/main_ut.rs new file mode 100644 index 0000000..730facf --- /dev/null +++ b/src/main_ut.rs @@ -0,0 +1,24 @@ +use super::{decode_workspace_handoff, encode_workspace_handoff}; +use std::path::Path; + +#[test] +fn workspace_handoff_round_trips_a_path() { + let frame = encode_workspace_handoff(b"/workspace/project").unwrap(); + let path = decode_workspace_handoff(&frame).unwrap(); + + assert_eq!(path, Path::new("/workspace/project")); +} + +#[test] +fn ui_message_is_not_a_workspace_handoff() { + let ui_message = b"@popup\0info\0Bunkerbox\0Starting...\0\n"; + + assert!(decode_workspace_handoff(ui_message).is_err()); +} + +#[test] +fn workspace_handoff_rejects_truncated_payload() { + let frame = encode_workspace_handoff(b"/workspace/project").unwrap(); + + assert!(decode_workspace_handoff(&frame[..frame.len() - 1]).is_err()); +} diff --git a/src/proxy.rs b/src/proxy.rs index b56c28e..8f44e9f 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,3 +1,4 @@ +use crate::logging; use std::net::SocketAddr; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; @@ -33,12 +34,13 @@ impl FilterProxy { Ok((stream, _peer)) => { let allow = allow.clone(); tokio::spawn(async move { - // TODO: route to log socket - let _ = handle_client(stream, &allow).await; + if let Err(err) = handle_client(stream, &allow).await { + logging::diagnostic(&format!("bunkerbox-proxy: client failed: {err}")); + } }); } Err(e) => { - eprintln!("bunkerbox-proxy: accept error: {e}"); + logging::diagnostic(&format!("bunkerbox-proxy: accept error: {e}")); } } } diff --git a/src/sandbox/mod.rs b/src/sandbox/mod.rs index 088b747..7280b75 100644 --- a/src/sandbox/mod.rs +++ b/src/sandbox/mod.rs @@ -198,5 +198,5 @@ fn get_builtin_profile(name: &str) -> Result<&str, String> { } #[cfg(test)] -#[path = "ut.rs"] +#[path = "mod_ut.rs"] mod sandbox_tests; diff --git a/src/sandbox/ut.rs b/src/sandbox/mod_ut.rs similarity index 100% rename from src/sandbox/ut.rs rename to src/sandbox/mod_ut.rs diff --git a/src/vscomm/buildsys/mod.rs b/src/vscomm/buildsys/mod.rs index 8813fce..034b047 100644 --- a/src/vscomm/buildsys/mod.rs +++ b/src/vscomm/buildsys/mod.rs @@ -52,5 +52,5 @@ mod npm; mod python; #[cfg(test)] -#[path = "ut.rs"] +#[path = "mod_ut.rs"] mod buildsys_tests; diff --git a/src/vscomm/buildsys/ut.rs b/src/vscomm/buildsys/mod_ut.rs similarity index 100% rename from src/vscomm/buildsys/ut.rs rename to src/vscomm/buildsys/mod_ut.rs diff --git a/src/vscomm/mod.rs b/src/vscomm/mod.rs index 8fc0c38..00365f6 100644 --- a/src/vscomm/mod.rs +++ b/src/vscomm/mod.rs @@ -281,41 +281,5 @@ pub fn parse_triggers(options: &str) -> Vec { } #[cfg(test)] -mod tests { - use super::{validate_exec_request, ExecRequest, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT}; - - #[test] - fn execution_and_tui_channels_are_distinct() { - assert_ne!(TOOLCHAIN_PORT, TUI_STATUS_PORT); - assert_ne!(FrameType::ExecReq as u16, FrameType::UiCommand as u16); - } - - #[test] - fn reject_nul_in_request_argument() { - let request = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: vec!["build\0".into()], env: Vec::new() }; - - let err = validate_exec_request(&request).unwrap_err(); - assert_eq!(err, "request argument 0 contains a NUL byte"); - } - - #[test] - fn reject_invalid_request_environment_key() { - let request = - ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: Vec::new(), env: vec![("BAD=KEY".into(), "value".into())] }; - - let err = validate_exec_request(&request).unwrap_err(); - assert_eq!(err, "request environment key 0 contains '='"); - } - - #[test] - fn accept_valid_cargo_request() { - let request = ExecRequest { - cwd: "/workspace".into(), - command: "cargo".into(), - args: vec!["build".into(), "--target-dir".into(), "target/debug".into()], - env: vec![("CARGO_TERM_COLOR".into(), "always".into())], - }; - - assert!(validate_exec_request(&request).is_ok()); - } -} +#[path = "mod_ut.rs"] +mod vscomm_tests; diff --git a/src/vscomm/mod_ut.rs b/src/vscomm/mod_ut.rs new file mode 100644 index 0000000..f52e0a9 --- /dev/null +++ b/src/vscomm/mod_ut.rs @@ -0,0 +1,35 @@ +use super::{validate_exec_request, ExecRequest, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT}; + +#[test] +fn execution_and_tui_channels_are_distinct() { + assert_ne!(TOOLCHAIN_PORT, TUI_STATUS_PORT); + assert_ne!(FrameType::ExecReq as u16, FrameType::UiCommand as u16); +} + +#[test] +fn reject_nul_in_request_argument() { + let request = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: vec!["build\0".into()], env: Vec::new() }; + + let err = validate_exec_request(&request).unwrap_err(); + assert_eq!(err, "request argument 0 contains a NUL byte"); +} + +#[test] +fn reject_invalid_request_environment_key() { + let request = ExecRequest { cwd: "/workspace".into(), command: "cargo".into(), args: Vec::new(), env: vec![("BAD=KEY".into(), "value".into())] }; + + let err = validate_exec_request(&request).unwrap_err(); + assert_eq!(err, "request environment key 0 contains '='"); +} + +#[test] +fn accept_valid_cargo_request() { + let request = ExecRequest { + cwd: "/workspace".into(), + command: "cargo".into(), + args: vec!["build".into(), "--target-dir".into(), "target/debug".into()], + env: vec![("CARGO_TERM_COLOR".into(), "always".into())], + }; + + assert!(validate_exec_request(&request).is_ok()); +} From fcc974e91b83ec9c6b29af6a4a4693524489d641 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 18:51:53 +0200 Subject: [PATCH 09/16] Update docs --- docs/guides/passthrough.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/guides/passthrough.md b/docs/guides/passthrough.md index 25116d6..61f1798 100644 --- a/docs/guides/passthrough.md +++ b/docs/guides/passthrough.md @@ -25,7 +25,9 @@ When the AI agent invokes one of those commands, the symlink points to `bunkerbox-vscomm`, which proxies the call through a virtio-vsock channel to a daemon running on the host. The daemon checks the whitelist one more time, spawns the real command inside the overlay workspace at `.bunkerbox/workspace/`, -and streams stdout, stderr, and the exit code back. +consumes stdout and stderr, and returns only the exit code. Command output is +discarded unless `--log PATH` was requested, in which case it is written to the +log file instead of the terminal. ``` ┌─ Bunkerbox VM ──────────────────────────────────────┐ @@ -47,15 +49,16 @@ and streams stdout, stderr, and the exit code back. │ ├─ whitelist check: "make *" ✓ │ │ ├─ cd .bunkerbox/workspace/ │ │ ├─ spawn make build │ -│ ├─ stream stdout / stderr back │ +│ ├─ log or discard stdout / stderr │ │ └─ send exit code │ └────────────────────────────────────────────────────┘ ``` -The AI agent sees standard output exactly as if `make` ran locally. The host -daemon runs inside the overlay workspace, so all output — compiled binaries, -generated files, test results — lands in the upper layer of the overlay and is -auto-synced back to your real repo when the container exits. +The host daemon runs inside the overlay workspace, so all output — compiled +binaries, generated files, and test results — lands in the upper layer of the +overlay and is auto-synced back to your real repo when the container exits. +The command-vsock helper never writes returned command output or diagnostics to +the Kata terminal. Use `--log PATH` when command output is needed for diagnosis. The command channel uses vsock port `9999`. TUI status and dialog commands use the separate `bunkerbox-status` client and vsock port `10000`; the From 3db840687d9a3cb8206dc2318e83dcba1a1f75c5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 20:04:33 +0200 Subject: [PATCH 10/16] Update docs --- docs/guides/passthrough.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/guides/passthrough.md b/docs/guides/passthrough.md index 61f1798..ed06f12 100644 --- a/docs/guides/passthrough.md +++ b/docs/guides/passthrough.md @@ -25,9 +25,8 @@ When the AI agent invokes one of those commands, the symlink points to `bunkerbox-vscomm`, which proxies the call through a virtio-vsock channel to a daemon running on the host. The daemon checks the whitelist one more time, spawns the real command inside the overlay workspace at `.bunkerbox/workspace/`, -consumes stdout and stderr, and returns only the exit code. Command output is -discarded unless `--log PATH` was requested, in which case it is written to the -log file instead of the terminal. +and streams the requested command's stdout, stderr, and exit code back. Daemon +and sandbox-launcher diagnostics are kept separate from the command stream. ``` ┌─ Bunkerbox VM ──────────────────────────────────────┐ @@ -49,20 +48,22 @@ log file instead of the terminal. │ ├─ whitelist check: "make *" ✓ │ │ ├─ cd .bunkerbox/workspace/ │ │ ├─ spawn make build │ -│ ├─ log or discard stdout / stderr │ +│ ├─ stream stdout / stderr back │ │ └─ send exit code │ └────────────────────────────────────────────────────┘ ``` -The host daemon runs inside the overlay workspace, so all output — compiled -binaries, generated files, and test results — lands in the upper layer of the -overlay and is auto-synced back to your real repo when the container exits. -The command-vsock helper never writes returned command output or diagnostics to -the Kata terminal. Use `--log PATH` when command output is needed for diagnosis. +The AI agent sees the requested command's standard output and standard error as +if it ran locally. The host daemon runs inside the overlay workspace, so all +output — compiled binaries, generated files, and test results — lands in the +upper layer of the overlay and is auto-synced back to your real repo when the +container exits. Bunkerbox and sandbox-launcher diagnostics never enter the +command stream; use `--log PATH` to retain those diagnostics. The command channel uses vsock port `9999`. TUI status and dialog commands use the separate `bunkerbox-status` client and vsock port `10000`; the -`bunkerbox-vscomm` command client never opens the TUI channel. +`bunkerbox-vscomm` command client remains silent when its own protocol fails +and makes a best-effort error notification through port `10000`. ## Configuration From 4ad9b7390ccb423e0fe5588d5ca8b3d6b89662eb Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 20:04:57 +0200 Subject: [PATCH 11/16] Add popup error bubble to capture internal errors --- src/bin/bunkerbox-vscomm.rs | 42 ++++++++-- src/clidef.rs | 2 +- src/daemon.rs | 154 +++++++++++++++++++++++++++++++++--- src/tui.rs | 85 +++++++++++++++++++- 4 files changed, 262 insertions(+), 21 deletions(-) diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index fd6fdf8..1732ab2 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -8,12 +8,13 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{validate_exec_request, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, VSCOMM_BIN_DIR}; +use vscomm::{encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, TUI_STATUS_PORT, TOOLCHAIN_PORT, VSCOMM_BIN_DIR}; const HOST_CID: u32 = 2; fn main() { - if run().is_err() { + if let Err(err) = run() { + notify_tui_error(&err); std::process::exit(1); } } @@ -54,20 +55,45 @@ fn run() -> Result<(), String> { } fn handle_response(response: Frame) -> Result, String> { + let mut stdout = io::stdout(); + let mut stderr = io::stderr(); + handle_response_to(response, &mut stdout, &mut stderr) +} + +fn handle_response_to(response: Frame, stdout: &mut WOut, stderr: &mut WErr) -> Result, String> { match response.frame_type { - FrameType::Stdout | FrameType::Stderr => Ok(None), + FrameType::Stdout => { + stdout.write_all(&response.payload).map_err(|e| format!("stdout: {e}"))?; + stdout.flush().map_err(|e| format!("flush stdout: {e}"))?; + Ok(None) + } + FrameType::Stderr => { + stderr.write_all(&response.payload).map_err(|e| format!("stderr: {e}"))?; + stderr.flush().map_err(|e| format!("flush stderr: {e}"))?; + Ok(None) + } FrameType::Exit => { - if response.payload.len() >= 4 { - let code = i32::from_le_bytes([response.payload[0], response.payload[1], response.payload[2], response.payload[3]]); - Ok(Some(code)) - } else { - Ok(Some(0)) + if response.payload.len() != 4 { + return Err("invalid exit frame".to_string()); } + let code = i32::from_le_bytes([response.payload[0], response.payload[1], response.payload[2], response.payload[3]]); + Ok(Some(code)) } _ => Err(format!("unexpected frame type from host: {:?}", response.frame_type as u16)), } } +fn notify_tui_error(message: &str) { + let Ok(mut stream) = vsock_connect(HOST_CID, TUI_STATUS_PORT) else { + return; + }; + + let payload = encode_ui_payload("error", "show", "bunkerbox-vscomm", message); + let frame = Frame::new(FrameType::UiCommand, payload); + let _ = frame.write(&mut stream); + let _ = stream.flush(); +} + fn install_symlinks() -> Result<(), String> { let config_path = find_config().ok_or_else(|| "no whitelist config found".to_string())?; diff --git a/src/clidef.rs b/src/clidef.rs index 60973dc..c88325a 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -48,7 +48,7 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("share").long("share").help("Override bunkerbox share directory")) .arg(Arg::new("workspace").long("workspace").value_parser(["share", "clone"]).help("Override workspace mode: share or clone")) .arg(Arg::new("verbose").long("verbose").action(ArgAction::SetTrue).help("Print log messages to stderr")) - .arg(Arg::new("log").long("log").value_name("PATH").help("Write diagnostics and proxied command output to the given file")) + .arg(Arg::new("log").long("log").value_name("PATH").help("Write Bunkerbox and sandbox diagnostics to the given file")) .arg(help_arg()) .arg(Arg::new("version").short('v').long("version").action(ArgAction::SetTrue).help("Get the current version.")) .disable_help_flag(true) diff --git a/src/daemon.rs b/src/daemon.rs index 732e724..096d7f1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3,12 +3,24 @@ use crate::logging; use crate::proxy::FilterProxy; use crate::sandbox::{resolve_profile, MergedProfile, NetworkMode}; use crate::vscomm::{validate_exec_request, validate_process_path, validate_process_string, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::os::fd::{AsRawFd, FromRawFd, RawFd}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; +const BWRAP_STATUS_FD: RawFd = 3; + +enum ChildEvent { + Output(FrameType, Vec), + StreamClosed, + LauncherStarted, + LauncherFailed(String), +} + struct VsockSession { passthrough: Arc>, env_mode: EnvMode, @@ -114,15 +126,13 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess let req = read_exec_request(&mut reader).await?; if let Err(err) = validate_exec_request(&req) { - let msg = format!("bunkerbox-vscomm: invalid request: {err}\n"); - write_frame(&mut writer, &Frame::new(FrameType::Stderr, msg.into_bytes())).await?; + logging::diagnostic(&format!("bunkerbox-vscomm: invalid request: {err}")); write_frame(&mut writer, &Frame::new(FrameType::Exit, 1i32.to_le_bytes().to_vec())).await?; return Ok(()); } if !is_allowed(&session.passthrough, &req.command, &req.args) { - let msg = format!("bunkerbox-vscomm: command '{}' not whitelisted\n", req.command); - write_frame(&mut writer, &Frame::new(FrameType::Stderr, msg.into_bytes())).await?; + logging::diagnostic(&format!("bunkerbox-vscomm: command '{}' not whitelisted", req.command)); write_frame(&mut writer, &Frame::new(FrameType::Exit, 1i32.to_le_bytes().to_vec())).await?; return Ok(()); } @@ -130,10 +140,8 @@ async fn handle_connection(stream: tokio_vsock::VsockStream, session: &VsockSess let command = req.command.clone(); if let Err(err) = execute_request(&mut writer, session, &req).await { logging::diagnostic(&format!("bunkerbox: toolchain command '{command}' failed: {err}")); - let msg = format!("bunkerbox-vscomm: {err}\n"); - let _ = write_frame(&mut writer, &Frame::new(FrameType::Stderr, msg.into_bytes())).await; let _ = write_frame(&mut writer, &Frame::new(FrameType::Exit, 1i32.to_le_bytes().to_vec())).await; - return Err(err); + return Ok(()); } Ok(()) @@ -148,7 +156,16 @@ async fn execute_request(writer: &mut W, session: &Vso PathBuf::from(&req.cwd) }; + let (status_reader, status_writer) = if session.merged_profile.is_some() { + let (reader, writer) = bwrap_status_pipe()?; + (Some(reader), Some(writer)) + } else { + (None, None) + }; let mut cmd = build_command(session, req, &host_cwd, &sandbox_cwd)?; + if let Some(status_writer) = status_writer.as_ref() { + attach_bwrap_status_fd(&mut cmd, status_writer.as_raw_fd()); + } cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); @@ -156,12 +173,52 @@ async fn execute_request(writer: &mut W, session: &Vso let launcher = if session.merged_profile.is_some() { "bwrap" } else { &req.command }; let mut child = cmd.spawn().map_err(|e| format!("spawn {launcher} for command '{}': {e}", req.command))?; + drop(status_writer); let child_stdout = child.stdout.take().ok_or_else(|| "no stdout".to_string())?; let child_stderr = child.stderr.take().ok_or_else(|| "no stderr".to_string())?; - let stdout_task = tokio::spawn(async move { pump_to_log(child_stdout, "stdout").await }); - let stderr_task = tokio::spawn(async move { pump_to_log(child_stderr, "stderr").await }); + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + let stdout_task = tokio::spawn(pump_to_channel(child_stdout, FrameType::Stdout, event_tx.clone())); + let stderr_task = tokio::spawn(pump_to_channel(child_stderr, FrameType::Stderr, event_tx.clone())); + let status_task = status_reader.map(|reader| { + let status_tx = event_tx.clone(); + tokio::task::spawn_blocking(move || monitor_bwrap_status(reader, status_tx)) + }); + drop(event_tx); + let mut launcher_started = session.merged_profile.is_none(); + let mut launcher_failed = false; + let mut closed_streams = 0; + let mut buffered_output = Vec::new(); + + while closed_streams < 2 || (session.merged_profile.is_some() && !launcher_started && !launcher_failed) { + let Some(event) = event_rx.recv().await else { break }; + match event { + ChildEvent::Output(frame_type, data) if launcher_failed => { + let stream = if matches!(frame_type, FrameType::Stdout) { "bwrap stdout" } else { "bwrap stderr" }; + logging::diagnostic_bytes(stream, &data); + } + ChildEvent::Output(frame_type, data) if launcher_started => { + write_frame(writer, &Frame::new(frame_type, data)).await?; + } + ChildEvent::Output(frame_type, data) => buffered_output.push((frame_type, data)), + ChildEvent::StreamClosed => closed_streams += 1, + ChildEvent::LauncherStarted => { + launcher_started = true; + for (frame_type, data) in buffered_output.drain(..) { + write_frame(writer, &Frame::new(frame_type, data)).await?; + } + } + ChildEvent::LauncherFailed(err) => { + launcher_failed = true; + logging::diagnostic(&format!("bwrap setup failed: {err}")); + for (frame_type, data) in buffered_output.drain(..) { + let stream = if matches!(frame_type, FrameType::Stdout) { "bwrap stdout" } else { "bwrap stderr" }; + logging::diagnostic_bytes(stream, &data); + } + } + } + } let status = child.wait().await.map_err(|e| format!("wait {}: {e}", req.command))?; let exit_code = status.code().unwrap_or(-1); @@ -169,6 +226,9 @@ async fn execute_request(writer: &mut W, session: &Vso stdout_task.await.map_err(|e| format!("stdout task: {e}"))?; stderr_task.await.map_err(|e| format!("stderr task: {e}"))?; + if let Some(status_task) = status_task { + status_task.await.map_err(|e| format!("bwrap status task: {e}"))?; + } Ok(()) } @@ -266,6 +326,7 @@ fn build_command(session: &VsockSession, req: &ExecRequest, host_cwd: &Path, san } } + cmd.arg("--json-status-fd").arg(BWRAP_STATUS_FD.to_string()); cmd.arg("--"); cmd.arg(&req.command); for arg in &req.args { @@ -358,17 +419,84 @@ async fn read_exec_request(reader: &mut R) -> Result(mut reader: R, stream: &'static str) { +fn bwrap_status_pipe() -> Result<(File, File), String> { + let mut fds = [-1; 2]; + if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { + return Err(format!("create bwrap status pipe: {}", std::io::Error::last_os_error())); + } + + let reader = unsafe { File::from_raw_fd(fds[0]) }; + let writer = unsafe { File::from_raw_fd(fds[1]) }; + Ok((reader, writer)) +} + +fn attach_bwrap_status_fd(cmd: &mut Command, source_fd: RawFd) { + unsafe { + cmd.pre_exec(move || { + if libc::dup2(source_fd, BWRAP_STATUS_FD) == -1 { + return Err(std::io::Error::last_os_error()); + } + if source_fd != BWRAP_STATUS_FD { + libc::close(source_fd); + } + Ok(()) + }); + } +} + +fn monitor_bwrap_status(reader: File, tx: tokio::sync::mpsc::UnboundedSender) { + let mut started = false; + for line in BufReader::new(reader).lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + if !started { + let _ = tx.send(ChildEvent::LauncherFailed(format!("read status: {err}"))); + } + return; + } + }; + + let status: serde_json::Value = match serde_json::from_str(&line) { + Ok(status) => status, + Err(err) => { + if !started { + let _ = tx.send(ChildEvent::LauncherFailed(format!("invalid status JSON: {err}"))); + } + return; + } + }; + + if status.get("child-pid").is_some() && !started { + started = true; + let _ = tx.send(ChildEvent::LauncherStarted); + } else if !started { + if let Some(exit_code) = status.get("exit-code") { + let _ = tx.send(ChildEvent::LauncherFailed(format!("exited before command start with status {exit_code}"))); + return; + } + } + } + + if !started { + let _ = tx.send(ChildEvent::LauncherFailed("exited before command start".to_string())); + } +} + +async fn pump_to_channel(mut reader: R, frame_type: FrameType, tx: tokio::sync::mpsc::UnboundedSender) { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { - logging::diagnostic_bytes(stream, &buf[..n]); + if tx.send(ChildEvent::Output(frame_type, buf[..n].to_vec())).is_err() { + return; + } } Err(_) => break, } } + let _ = tx.send(ChildEvent::StreamClosed); } async fn write_frame(writer: &mut W, frame: &Frame) -> Result<(), String> { @@ -387,3 +515,7 @@ async fn write_frame(writer: &mut W, frame: &Frame) -> Ok(()) } + +#[cfg(test)] +#[path = "daemon_ut.rs"] +mod daemon_tests; diff --git a/src/tui.rs b/src/tui.rs index 96714a9..bc098e8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -3,7 +3,7 @@ use std::os::fd::AsRawFd; use std::os::unix::io::RawFd; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::Instant; +use std::time::{Duration, Instant}; use crossterm::cursor; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -11,6 +11,7 @@ use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use ratatui::backend::CrosstermBackend; use ratatui::prelude::*; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Widget, Wrap}; use ratatui::Terminal; @@ -31,12 +32,23 @@ const WIDGET_STATUS: &str = "status"; const WIDGET_POPUP: &str = "popup"; const WIDGET_SPINNER: &str = "spinner"; const WIDGET_PASSWORD: &str = "password"; +const WIDGET_ERROR: &str = "error"; const CMD_SHOW: &str = "show"; const CMD_HIDE: &str = "hide"; const CMD_SET: &str = "set"; const CMD_CLEAR: &str = "clear"; +const ERROR_TOAST_IN: Duration = Duration::from_millis(220); +const ERROR_TOAST_HOLD: Duration = Duration::from_secs(5); +const ERROR_TOAST_OUT: Duration = Duration::from_millis(260); + +struct ErrorToast { + title: String, + message: String, + shown_at: Instant, +} + pub struct PendingAction { pub widget: String, pub command: String, @@ -52,6 +64,7 @@ pub struct OverlayState { pub popup_title: Option, pub pending: Vec, pub has_error: bool, + error_toast: Option, pub hide_on_ascii: bool, pub hide_on_content: Option, pub last_content_scan: Instant, @@ -71,6 +84,7 @@ impl OverlayState { popup_title: None, pending: Vec::new(), has_error: false, + error_toast: None, hide_on_ascii: false, hide_on_content: None, last_content_scan: Instant::now(), @@ -79,6 +93,15 @@ impl OverlayState { } pub fn dispatch_ui_command(state: &mut OverlayState, widget: &str, command: &str, options: &str, value: &str) { + if widget == WIDGET_ERROR && command == CMD_SHOW { + state.error_toast = Some(ErrorToast { + title: if options.is_empty() { "Bunkerbox error".to_string() } else { options.chars().take(80).collect() }, + message: value.chars().take(512).collect(), + shown_at: Instant::now(), + }); + return; + } + if widget == WIDGET_POPUP && command == CMD_HIDE && !value.is_empty() { if value == "ASCII" { state.hide_on_ascii = true; @@ -607,6 +630,12 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over } state.popup.tick(); + if let Some(toast) = state.error_toast.as_ref() { + let lifetime = ERROR_TOAST_IN + ERROR_TOAST_HOLD + ERROR_TOAST_OUT; + if toast.shown_at.elapsed() >= lifetime { + state.error_toast = None; + } + } terminal.draw(|f| render_frame(f, term.screen(), &state)).map_err(|e| format!("draw: {e}"))?; } @@ -777,6 +806,7 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { { let buf = f.buffer_mut(); overlay.popup.render(area, buf); + render_error_toast(area, buf, overlay.error_toast.as_ref()); } let (cursor_row, cursor_col) = screen.cursor_position(); @@ -785,6 +815,59 @@ fn render_frame(f: &mut Frame, screen: &vt100::Screen, overlay: &OverlayState) { } } +fn render_error_toast(area: Rect, buf: &mut Buffer, toast: Option<&ErrorToast>) { + let Some(toast) = toast else { + return; + }; + + let elapsed = toast.shown_at.elapsed(); + let width = toast + .message + .lines() + .chain(std::iter::once(toast.title.as_str())) + .map(|line| line.chars().count() as u16) + .max() + .unwrap_or(24) + .saturating_add(8) + .max(28) + .min(area.width.saturating_sub(2)); + let height = (toast.message.lines().count().max(1) as u16 + 4).min(area.height.saturating_sub(2)); + if width < 4 || height < 3 { + return; + } + + let travel = width.saturating_add(2); + let target_x = area.right().saturating_sub(travel); + let offset = if elapsed < ERROR_TOAST_IN { + let progress = elapsed.as_secs_f64() / ERROR_TOAST_IN.as_secs_f64(); + ((1.0 - progress) * f64::from(travel)) as u16 + } else if elapsed < ERROR_TOAST_IN + ERROR_TOAST_HOLD { + 0 + } else { + let out_elapsed = elapsed - ERROR_TOAST_IN - ERROR_TOAST_HOLD; + let progress = (out_elapsed.as_secs_f64() / ERROR_TOAST_OUT.as_secs_f64()).min(1.0); + (progress * f64::from(travel)) as u16 + }; + let x = target_x.saturating_add(offset); + let y = area.y.saturating_add(1); + let canvas = Rect { x, y, width, height }; + + Clear.render(canvas, buf); + let block = Block::default() + .title(toast.title.as_str()) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::ERROR)) + .padding(Padding::horizontal(1)) + .style(Style::default().bg(palette::BG_1)); + let inner = block.inner(canvas); + block.render(canvas, buf); + Paragraph::new(toast.message.as_str()) + .style(Style::default().fg(palette::FG)) + .wrap(Wrap { trim: true }) + .render(inner, buf); +} + #[cfg(test)] #[path = "tui_ut.rs"] mod tui_tests; From 6266ca3af1f673c352611ecb898b520add8c5638 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 20:05:04 +0200 Subject: [PATCH 12/16] Add unit tests --- src/bunkerbox-vscomm_ut.rs | 13 +++++++++---- src/daemon_ut.rs | 26 ++++++++++++++++++++++++++ src/tui_ut.rs | 15 ++++++++++++++- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 src/daemon_ut.rs diff --git a/src/bunkerbox-vscomm_ut.rs b/src/bunkerbox-vscomm_ut.rs index f1201e5..9ddee4e 100644 --- a/src/bunkerbox-vscomm_ut.rs +++ b/src/bunkerbox-vscomm_ut.rs @@ -1,9 +1,14 @@ -use super::{handle_response, Frame, FrameType}; +use super::{handle_response, handle_response_to, Frame, FrameType}; #[test] -fn discard_stdout_and_stderr_frames() { - assert_eq!(handle_response(Frame::new(FrameType::Stdout, b"visible output".to_vec())).unwrap(), None); - assert_eq!(handle_response(Frame::new(FrameType::Stderr, b"/home/bo/Pictures/Screenshots/path.png".to_vec())).unwrap(), None); +fn forward_stdout_and_stderr_frames() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + assert_eq!(handle_response_to(Frame::new(FrameType::Stdout, b"visible output".to_vec()), &mut stdout, &mut stderr).unwrap(), None); + assert_eq!(handle_response_to(Frame::new(FrameType::Stderr, b"command failed".to_vec()), &mut stdout, &mut stderr).unwrap(), None); + assert_eq!(stdout, b"visible output"); + assert_eq!(stderr, b"command failed"); } #[test] diff --git a/src/daemon_ut.rs b/src/daemon_ut.rs new file mode 100644 index 0000000..1f056cd --- /dev/null +++ b/src/daemon_ut.rs @@ -0,0 +1,26 @@ +use super::{monitor_bwrap_status, ChildEvent}; +use std::io::Write; + +#[test] +fn bwrap_status_reports_command_start() { + let mut status = tempfile::NamedTempFile::new().unwrap(); + writeln!(status, "{{\"child-pid\":1234}}").unwrap(); + writeln!(status, "{{\"exit-code\":0}}").unwrap(); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + monitor_bwrap_status(status.reopen().unwrap(), tx); + + assert!(matches!(rx.try_recv().unwrap(), ChildEvent::LauncherStarted)); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn bwrap_status_reports_setup_failure_without_child() { + let mut status = tempfile::NamedTempFile::new().unwrap(); + writeln!(status, "{{\"exit-code\":1}}").unwrap(); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + monitor_bwrap_status(status.reopen().unwrap(), tx); + + assert!(matches!(rx.try_recv().unwrap(), ChildEvent::LauncherFailed(_))); +} diff --git a/src/tui_ut.rs b/src/tui_ut.rs index 0782a8d..f6d680e 100644 --- a/src/tui_ut.rs +++ b/src/tui_ut.rs @@ -1,4 +1,17 @@ -use super::Term; +use super::{dispatch_ui_command, OverlayState, Term}; + +#[test] +fn internal_error_creates_a_non_modal_toast() { + let mut state = OverlayState::new(); + + dispatch_ui_command(&mut state, "error", "show", "bunkerbox-vscomm", "connection failed"); + + assert!(state.error_toast.is_some()); + assert!(!state.popup.visible); + let toast = state.error_toast.as_ref().unwrap(); + assert_eq!(toast.title, "bunkerbox-vscomm"); + assert_eq!(toast.message, "connection failed"); +} #[test] fn cursor_report_uses_position_after_prior_bytes_in_same_chunk() { From eae81559bd6d1955db09c1320f28321bb90e6bd4 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 20:55:03 +0200 Subject: [PATCH 13/16] Redirect system errors to the logfile --- src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index f2f6a38..83ecce4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -457,6 +457,10 @@ async fn status_listener( } if let Some((widget, cmd, opts, val)) = vscomm::decode_ui_payload(&payload) { + if widget == "error" && cmd == "show" { + let title = if opts.is_empty() { "Bunkerbox error" } else { opts }; + logging::diagnostic(&format!("TUI error [{title}]: {val}")); + } tui::dispatch_ui_command(&mut overlay.lock().unwrap(), widget, cmd, opts, val); } }); From 0d1cceac92ce798b9554244d3a1c0aeb8440cc7c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 20:55:30 +0200 Subject: [PATCH 14/16] Formatter --- src/bin/bunkerbox-vscomm.rs | 2 +- src/tui.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bin/bunkerbox-vscomm.rs b/src/bin/bunkerbox-vscomm.rs index 1732ab2..3cf906d 100644 --- a/src/bin/bunkerbox-vscomm.rs +++ b/src/bin/bunkerbox-vscomm.rs @@ -8,7 +8,7 @@ use std::mem; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use vscomm::{encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, TUI_STATUS_PORT, TOOLCHAIN_PORT, VSCOMM_BIN_DIR}; +use vscomm::{encode_ui_payload, validate_exec_request, ExecRequest, Frame, FrameType, TOOLCHAIN_PORT, TUI_STATUS_PORT, VSCOMM_BIN_DIR}; const HOST_CID: u32 = 2; diff --git a/src/tui.rs b/src/tui.rs index bc098e8..af6eecc 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -862,10 +862,7 @@ fn render_error_toast(area: Rect, buf: &mut Buffer, toast: Option<&ErrorToast>) .style(Style::default().bg(palette::BG_1)); let inner = block.inner(canvas); block.render(canvas, buf); - Paragraph::new(toast.message.as_str()) - .style(Style::default().fg(palette::FG)) - .wrap(Wrap { trim: true }) - .render(inner, buf); + Paragraph::new(toast.message.as_str()).style(Style::default().fg(palette::FG)).wrap(Wrap { trim: true }).render(inner, buf); } #[cfg(test)] From 718e26f78315e1185d43dde64309734cd1c48de7 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 21:09:06 +0200 Subject: [PATCH 15/16] Implement mouse capture --- src/tui.rs | 212 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 16 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index af6eecc..2aefed8 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,7 +6,9 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use crossterm::cursor; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::ExecutableCommand; use ratatui::backend::CrosstermBackend; @@ -49,6 +51,21 @@ struct ErrorToast { shown_at: Instant, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MouseTracking { + Off, + Normal, + Button, + Any, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MouseEncoding { + X10, + Urxvt, + Sgr, +} + pub struct PendingAction { pub widget: String, pub command: String, @@ -191,6 +208,11 @@ struct Term { g1_dec_special_graphics: bool, using_g1_charset: bool, application_cursor_keys: bool, + mouse_normal: bool, + mouse_button: bool, + mouse_any: bool, + mouse_sgr: bool, + mouse_urxvt: bool, csi_bytes: Vec, } @@ -215,6 +237,11 @@ impl Term { g1_dec_special_graphics: false, using_g1_charset: false, application_cursor_keys: false, + mouse_normal: false, + mouse_button: false, + mouse_any: false, + mouse_sgr: false, + mouse_urxvt: false, csi_bytes: Vec::new(), } } @@ -243,6 +270,28 @@ impl Term { self.application_cursor_keys } + fn mouse_tracking(&self) -> MouseTracking { + if self.mouse_any { + MouseTracking::Any + } else if self.mouse_button { + MouseTracking::Button + } else if self.mouse_normal { + MouseTracking::Normal + } else { + MouseTracking::Off + } + } + + fn mouse_encoding(&self) -> MouseEncoding { + if self.mouse_sgr { + MouseEncoding::Sgr + } else if self.mouse_urxvt { + MouseEncoding::Urxvt + } else { + MouseEncoding::X10 + } + } + fn active_dec_special_graphics(&self) -> bool { if self.using_g1_charset { self.g1_dec_special_graphics @@ -342,6 +391,8 @@ impl Term { } fn handle_csi_complete(&mut self, translated: &mut [u8]) -> bool { + self.update_private_modes(); + match self.csi_bytes.as_slice() { b"?1h" => self.application_cursor_keys = true, b"?1l" => self.application_cursor_keys = false, @@ -362,6 +413,37 @@ impl Term { false } + + fn update_private_modes(&mut self) { + let bytes = self.csi_bytes.clone(); + if bytes.first() != Some(&b'?') || bytes.len() < 3 { + return; + } + + let Some((&final_byte, params)) = bytes.split_last() else { + return; + }; + let enabled = match final_byte { + b'h' => true, + b'l' => false, + _ => return, + }; + + for raw_mode in params[1..].split(|byte| *byte == b';') { + let Ok(mode) = std::str::from_utf8(raw_mode).unwrap_or_default().parse::() else { + continue; + }; + match mode { + 1000 => self.mouse_normal = enabled, + 1002 => self.mouse_button = enabled, + 1003 => self.mouse_any = enabled, + 1006 => self.mouse_sgr = enabled, + 1015 => self.mouse_urxvt = enabled, + 1005 => {} + _ => {} + } + } + } } /// Converts a single byte from the DEC Special Graphics table to its Unicode @@ -487,6 +569,7 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over let mut last_cols = cols; let mut status_buf = Vec::new(); + let mut mouse_capture_enabled = false; unsafe { libc::signal(libc::SIGWINCH, handle_sigwinch as *const () as libc::sighandler_t); @@ -520,8 +603,7 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over if err.raw_os_error() == Some(libc::EINTR) { continue; } - terminal.backend_mut().execute(LeaveAlternateScreen).ok(); - terminal::disable_raw_mode().ok(); + cleanup_terminal(&mut terminal, mouse_capture_enabled); return Err(format!("poll: {err}")); } @@ -531,6 +613,19 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over let n = unsafe { libc::read(master_fd, pty_buf.as_mut_ptr() as *mut libc::c_void, pty_buf.len()) }; if n > 0 { term.process(&pty_buf[..n as usize]); + let wants_mouse_capture = term.mouse_tracking() != MouseTracking::Off; + if wants_mouse_capture != mouse_capture_enabled { + let result = if wants_mouse_capture { + terminal.backend_mut().execute(EnableMouseCapture).map(|_| ()) + } else { + terminal.backend_mut().execute(DisableMouseCapture).map(|_| ()) + }; + if let Err(err) = result { + cleanup_terminal(&mut terminal, mouse_capture_enabled); + return Err(format!("mouse capture: {err}")); + } + mouse_capture_enabled = wants_mouse_capture; + } for response in term.drain_responses() { unsafe { libc::write(master_fd, response.as_ptr() as *const libc::c_void, response.len()); @@ -544,18 +639,30 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over } if fds[1].revents & libc::POLLIN != 0 { - if let Ok(Event::Key(key)) = event::read() { - if key.kind != KeyEventKind::Press { - continue; - } + if let Ok(input_event) = event::read() { + match input_event { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { + continue; + } - let is_password = overlay.lock().is_ok_and(|s| matches!(s.popup.content, popup::PopupContent::Password { .. })); - if is_password { - handle_password_key(&overlay, status_fd, key); - } else if let Some(bytes) = key_to_bytes(&key, term.application_cursor_keys()) { - unsafe { - libc::write(master_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()); + let is_password = overlay.lock().is_ok_and(|s| matches!(s.popup.content, popup::PopupContent::Password { .. })); + if is_password { + handle_password_key(&overlay, status_fd, key); + } else if let Some(bytes) = key_to_bytes(&key, term.application_cursor_keys()) { + unsafe { + libc::write(master_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()); + } + } } + Event::Mouse(mouse) => { + if let Some(bytes) = mouse_to_bytes(mouse, term.mouse_tracking(), term.mouse_encoding()) { + unsafe { + libc::write(master_fd, bytes.as_ptr() as *const libc::c_void, bytes.len()); + } + } + } + _ => {} } } } @@ -637,17 +744,27 @@ pub fn event_loop(master_fd: RawFd, rows: u16, cols: u16, status_fd: RawFd, over } } - terminal.draw(|f| render_frame(f, term.screen(), &state)).map_err(|e| format!("draw: {e}"))?; + if let Err(err) = terminal.draw(|f| render_frame(f, term.screen(), &state)) { + cleanup_terminal(&mut terminal, mouse_capture_enabled); + return Err(format!("draw: {err}")); + } } } + cleanup_terminal(&mut terminal, mouse_capture_enabled); + + Ok(()) +} + +fn cleanup_terminal(terminal: &mut Terminal>, mouse_capture_enabled: bool) { + if mouse_capture_enabled { + terminal.backend_mut().execute(DisableMouseCapture).ok(); + } terminal.backend_mut().execute(LeaveAlternateScreen).ok(); terminal::disable_raw_mode().ok(); unsafe { libc::signal(libc::SIGWINCH, libc::SIG_DFL); } - - Ok(()) } fn handle_password_key(overlay: &Arc>, status_fd: RawFd, key: KeyEvent) { @@ -706,6 +823,69 @@ fn key_to_bytes(key: &KeyEvent, app_cursor: bool) -> Option> { } } +fn mouse_to_bytes(event: MouseEvent, tracking: MouseTracking, encoding: MouseEncoding) -> Option> { + let (base_code, is_drag, is_release) = match event.kind { + MouseEventKind::Down(button) => (mouse_button_code(button), false, false), + MouseEventKind::Up(button) => (mouse_button_code(button), false, true), + MouseEventKind::Drag(button) => (mouse_button_code(button), true, false), + MouseEventKind::Moved => (3, true, false), + MouseEventKind::ScrollUp => (64, false, false), + MouseEventKind::ScrollDown => (65, false, false), + MouseEventKind::ScrollLeft => (66, false, false), + MouseEventKind::ScrollRight => (67, false, false), + }; + + match (tracking, event.kind) { + (MouseTracking::Off, _) => return None, + (MouseTracking::Normal, MouseEventKind::Drag(_) | MouseEventKind::Moved) => return None, + (MouseTracking::Button, MouseEventKind::Moved) => return None, + _ => {} + } + + let mut code = base_code; + if event.modifiers.contains(KeyModifiers::SHIFT) { + code += 4; + } + if event.modifiers.contains(KeyModifiers::ALT) { + code += 8; + } + if event.modifiers.contains(KeyModifiers::CONTROL) { + code += 16; + } + if is_drag { + code += 32; + } + + let column = u32::from(event.column) + 1; + let row = u32::from(event.row) + 1; + + match encoding { + MouseEncoding::Sgr => { + let suffix = if is_release { 'm' } else { 'M' }; + Some(format!("\x1b[<{};{};{}{}", code, column, row, suffix).into_bytes()) + } + MouseEncoding::Urxvt => { + let legacy_code = if is_release { 3 } else { code }; + Some(format!("\x1b[{};{};{}M", legacy_code + 32, column, row).into_bytes()) + } + MouseEncoding::X10 => { + let legacy_code = if is_release { 3 } else { code }; + if column > 223 || row > 223 || legacy_code + 32 > 255 { + return None; + } + Some(vec![0x1b, b'[', b'M', (legacy_code + 32) as u8, (column + 32) as u8, (row + 32) as u8]) + } + } +} + +fn mouse_button_code(button: MouseButton) -> u32 { + match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + } +} + fn fn_key(n: u8) -> Option> { match n { 1 => Some(b"\x1bOP".to_vec()), From b6c4031d96985e1fb3146b60b50b693640b167d6 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 1 Aug 2026 21:09:15 +0200 Subject: [PATCH 16/16] Add unit tests for mouse capture --- src/tui_ut.rs | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/tui_ut.rs b/src/tui_ut.rs index f6d680e..e2cc37e 100644 --- a/src/tui_ut.rs +++ b/src/tui_ut.rs @@ -1,4 +1,5 @@ -use super::{dispatch_ui_command, OverlayState, Term}; +use super::{dispatch_ui_command, mouse_to_bytes, MouseEncoding, MouseTracking, OverlayState, Term}; +use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; #[test] fn internal_error_creates_a_non_modal_toast() { @@ -56,3 +57,47 @@ fn window_size_report_handles_split_query() { assert_eq!(term.drain_responses(), vec![b"\x1b[8;24;80t".to_vec()]); } + +#[test] +fn mouse_modes_track_multiple_private_parameters() { + let mut term = Term::new(24, 80); + + term.process(b"\x1b[?1000;1006h"); + + assert_eq!(term.mouse_tracking(), MouseTracking::Normal); + assert_eq!(term.mouse_encoding(), MouseEncoding::Sgr); + + term.process(b"\x1b[?1000l\x1b[?1006l"); + assert_eq!(term.mouse_tracking(), MouseTracking::Off); + assert_eq!(term.mouse_encoding(), MouseEncoding::X10); +} + +#[test] +fn sgr_mouse_click_uses_one_based_coordinates() { + let event = MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 9, row: 4, modifiers: KeyModifiers::NONE }; + + assert_eq!(mouse_to_bytes(event, MouseTracking::Normal, MouseEncoding::Sgr), Some(b"\x1b[<0;10;5M".to_vec())); +} + +#[test] +fn sgr_mouse_release_and_modifiers_are_encoded() { + let event = + MouseEvent { kind: MouseEventKind::Up(MouseButton::Right), column: 2, row: 3, modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL }; + + assert_eq!(mouse_to_bytes(event, MouseTracking::Normal, MouseEncoding::Sgr), Some(b"\x1b[<22;3;4m".to_vec())); +} + +#[test] +fn legacy_mouse_encodings_use_their_wire_formats() { + let event = MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 0, row: 0, modifiers: KeyModifiers::NONE }; + assert_eq!(mouse_to_bytes(event, MouseTracking::Normal, MouseEncoding::X10), Some(vec![0x1b, b'[', b'M', 32, 33, 33])); + assert_eq!(mouse_to_bytes(event, MouseTracking::Normal, MouseEncoding::Urxvt), Some(b"\x1b[32;1;1M".to_vec())); +} + +#[test] +fn mouse_motion_requires_the_requested_tracking_level() { + let event = MouseEvent { kind: MouseEventKind::Moved, column: 5, row: 6, modifiers: KeyModifiers::NONE }; + + assert_eq!(mouse_to_bytes(event, MouseTracking::Button, MouseEncoding::Sgr), None); + assert_eq!(mouse_to_bytes(event, MouseTracking::Any, MouseEncoding::Sgr), Some(b"\x1b[<35;6;7M".to_vec())); +}