From 232a00cc9e504124c2f6efb15ce4a4780864dfb5 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sat, 30 May 2026 14:26:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(hh):=20P3=20=E2=80=94=20summonable=20sandb?= =?UTF-8?q?ox=20+=20shared=20PTY=20=E2=9B=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collaborative sandbox over the same zero-knowledge encrypted channel: - sbx.rs: SandboxBackend (Local / Docker / Multipass) spawning a shell in a PTY (portable-pty); reader thread pumps output to the broker. - Broker (owner's client): /sbx launch [backend] [image] boots the sandbox and relays PTY output as encrypted {"_sbx":"data"} frames; /sbx stop tears down. PTY input arrives as {"_sbx":"input"} frames and is written back. - All clients render the shared terminal from data frames via a vt100 parser; F2 toggles drive mode (keystrokes -> input frames, incl. Ctrl-C); esc releases. - ui.rs: sandbox pane (split below chat) with drive indicator. - Server stays zero-knowledge: PTY bytes are Fernet-encrypted like chat/files; the VM runs on the initiator's client, never the server. Tests (cargo test, 4 pass): PTY I/O round-trip + headless end-to-end relay (PTY -> _sbx frame encode -> decode -> vt100 screen shows command output). Note: Multipass assumes the instance is launched separately (lifecycle = P3b); per-user unix accounts + sudo delegation = P4. Co-Authored-By: Claude Opus 4.8 --- hh/Cargo.lock | 217 +++++++++++++++++++++++++++++++++++++++++++++-- hh/Cargo.toml | 4 + hh/src/app.rs | 203 ++++++++++++++++++++++++++++++++++++++++---- hh/src/main.rs | 1 + hh/src/net.rs | 59 +++++++++---- hh/src/sbx.rs | 224 +++++++++++++++++++++++++++++++++++++++++++++++++ hh/src/ui.rs | 37 +++++++- 7 files changed, 707 insertions(+), 38 deletions(-) create mode 100644 hh/src/sbx.rs diff --git a/hh/Cargo.lock b/hh/Cargo.lock index 12a1d0c..8e4d940 100644 --- a/hh/Cargo.lock +++ b/hh/Cargo.lock @@ -64,6 +64,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -104,6 +110,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -264,7 +276,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "futures-core", "mio", @@ -356,6 +368,12 @@ dependencies = [ "syn", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -397,6 +415,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -551,6 +580,7 @@ dependencies = [ "hkdf", "num-bigint", "num-traits", + "portable-pty", "rand 0.8.6", "ratatui", "reqwest", @@ -563,6 +593,7 @@ dependencies = [ "toml", "tungstenite", "url", + "vt100", ] [[package]] @@ -851,6 +882,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -900,6 +940,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -954,6 +1000,15 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "mio" version = "1.2.1" @@ -966,6 +1021,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1012,7 +1081,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -1084,12 +1153,39 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1252,7 +1348,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cassowary", "compact_str", "crossterm", @@ -1274,7 +1370,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -1343,7 +1439,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -1470,6 +1566,48 @@ dependencies = [ "serde", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1492,6 +1630,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -1628,6 +1782,15 @@ dependencies = [ "syn", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1807,7 +1970,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -1966,6 +2129,39 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "want" version = "0.3.1" @@ -2276,6 +2472,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 9d09c02..2bddab2 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -26,6 +26,10 @@ tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } rustls = "0.23" url = "2" +# sandbox (P3): PTY + terminal emulation +portable-pty = "0.8" +vt100 = "0.15" + # async + tui tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } diff --git a/hh/src/app.rs b/hh/src/app.rs index 6686328..c4cd22f 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -1,21 +1,28 @@ //! TUI application state, network event model, and the async run loop. use crate::net::{self, Session}; +use crate::sbx; use crate::theme::Theme; use crate::ui; use anyhow::Result; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; -use crossterm::execute; use futures_util::{SinkExt, StreamExt}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; +use serde_json::json; use std::time::Duration; -use tokio::sync::mpsc::unbounded_channel; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; use tokio_tungstenite::tungstenite::Message as WsMsg; +pub const SBX_ROWS: u16 = 24; +pub const SBX_COLS: u16 = 80; + /// One rendered chat row. #[derive(Clone)] pub struct ChatLine { @@ -38,9 +45,18 @@ pub enum Net { Roster { users: Vec, capacity: usize }, Joined(String), Left(String), + SbxStatus { backend: String, ready: bool }, + SbxData(Vec), + SbxInput(Vec), Closed, } +/// Local view of the shared sandbox terminal (everyone renders from `data`). +pub struct SbxView { + pub parser: vt100::Parser, + pub backend: String, +} + pub struct App { pub me: String, pub lines: Vec, @@ -48,6 +64,8 @@ pub struct App { pub capacity: usize, pub input: String, pub connected: bool, + pub sandbox: Option, + pub driving: bool, } impl App { @@ -59,6 +77,8 @@ impl App { capacity: 0, input: String::new(), connected: false, + sandbox: None, + driving: false, } } @@ -78,6 +98,7 @@ impl App { self.users = users; self.connected = true; self.sys(format!("joined as {} ⛧", self.me)); + self.sys("/sbx launch [local|docker|multipass] · /sbx stop · F2 to drive"); } Net::Message(l) => self.lines.push(l), Net::Roster { users, capacity } => { @@ -91,6 +112,26 @@ impl App { self.sys(format!("{name} left")); } } + Net::SbxStatus { backend, ready } => { + if ready { + self.sandbox = Some(SbxView { + parser: vt100::Parser::new(SBX_ROWS, SBX_COLS, 0), + backend: backend.clone(), + }); + self.sys(format!("⛧ sandbox summoned ({backend}) — F2 to drive")); + } else { + self.sandbox = None; + self.driving = false; + self.sys("⛧ sandbox dismissed"); + } + } + Net::SbxData(bytes) => { + if let Some(v) = &mut self.sandbox { + v.parser.process(&bytes); + } + } + // Broker writes input to the PTY in the run loop; non-brokers ignore. + Net::SbxInput(_) => {} Net::Closed => { self.connected = false; self.sys("connection closed"); @@ -99,13 +140,47 @@ impl App { } } -/// Authenticate already done; connect the websocket and drive the UI. +/// Translate a key event into the bytes a PTY expects (drive mode). +fn key_to_pty(code: KeyCode, mods: KeyModifiers) -> Option> { + match code { + KeyCode::Char(c) => { + if mods.contains(KeyModifiers::CONTROL) { + let u = (c.to_ascii_uppercase() as u8).wrapping_sub(64); + Some(vec![u & 0x1f]) + } else { + Some(c.to_string().into_bytes()) + } + } + KeyCode::Enter => Some(vec![b'\r']), + KeyCode::Backspace => Some(vec![0x7f]), + KeyCode::Tab => Some(vec![b'\t']), + KeyCode::Up => Some(b"\x1b[A".to_vec()), + KeyCode::Down => Some(b"\x1b[B".to_vec()), + KeyCode::Right => Some(b"\x1b[C".to_vec()), + KeyCode::Left => Some(b"\x1b[D".to_vec()), + _ => None, + } +} + +/// Encrypt and send a JSON application frame over the chat channel. +async fn send_frame(write: &mut S, room: &fernet::Fernet, value: serde_json::Value) +where + S: SinkExt + Unpin, +{ + let ct = room.encrypt(value.to_string().as_bytes()); + let _ = write.send(WsMsg::Text(ct)).await; +} + pub async fn run(session: Session, theme: Theme) -> Result<()> { let ws = net::connect(&session).await?; let (mut write, read) = ws.split(); let (tx, mut rx) = unbounded_channel::(); tokio::spawn(net::reader(read, session.room.clone(), tx)); + // PTY output from a broker-owned sandbox (set on /sbx launch). + let (pty_tx, mut pty_rx): (_, UnboundedReceiver>) = unbounded_channel(); + let mut broker: Option = None; + enable_raw_mode()?; let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen)?; @@ -113,7 +188,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { let mut app = App::new(session.username.clone()); let mut events = EventStream::new(); - let mut tick = tokio::time::interval(Duration::from_millis(200)); + let mut tick = tokio::time::interval(Duration::from_millis(120)); let result = loop { if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) { @@ -124,22 +199,45 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { maybe = events.next() => { match maybe { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { - match (k.modifiers, k.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => break Ok(()), - (_, KeyCode::Esc) => break Ok(()), - (_, KeyCode::Enter) => { - let line = app.input.trim().to_string(); - app.input.clear(); - if !line.is_empty() && app.connected { - let ct = session.room.encrypt(line.as_bytes()); - if write.send(WsMsg::Text(ct)).await.is_err() { - app.connected = false; + // Global quit. + if k.modifiers.contains(KeyModifiers::CONTROL) + && matches!(k.code, KeyCode::Char('q')) { + break Ok(()); + } + // F2 toggles drive (only meaningful with a live sandbox). + if k.code == KeyCode::F(2) { + if app.sandbox.is_some() { + app.driving = !app.driving; + } + } else if app.driving { + // Drive mode: keystrokes go to the sandbox PTY. + if k.code == KeyCode::Esc { + app.driving = false; + } else if let Some(bytes) = key_to_pty(k.code, k.modifiers) { + send_frame(&mut write, &session.room, + json!({"_sbx":"input","b64": STANDARD.encode(&bytes)})).await; + } + } else { + // Chat / command mode. + match k.code { + KeyCode::Esc => break Ok(()), + KeyCode::Enter => { + let line = app.input.trim().to_string(); + app.input.clear(); + if line.starts_with("/sbx") { + handle_sbx_cmd(&line, &mut app, &mut broker, + &pty_tx, &mut write, &session.room).await; + } else if !line.is_empty() && app.connected { + let ct = session.room.encrypt(line.as_bytes()); + if write.send(WsMsg::Text(ct)).await.is_err() { + app.connected = false; + } } } + KeyCode::Backspace => { app.input.pop(); } + KeyCode::Char(c) => app.input.push(c), + _ => {} } - (_, KeyCode::Backspace) => { app.input.pop(); } - (_, KeyCode::Char(c)) => app.input.push(c), - _ => {} } } Some(Err(e)) => break Err(e.into()), @@ -148,16 +246,87 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> { } net = rx.recv() => { match net { + Some(Net::SbxInput(b)) => { + if let Some(sb) = &mut broker { let _ = sb.write_input(&b); } + } Some(n) => app.apply(n), None => break Ok(()), } } + pty = pty_rx.recv() => { + if let Some(bytes) = pty { + // Broker relays sandbox output to the whole coven (encrypted). + send_frame(&mut write, &session.room, + json!({"_sbx":"data","b64": STANDARD.encode(&bytes)})).await; + } + } _ = tick.tick() => {} } }; + if let Some(mut sb) = broker.take() { + sb.stop(); + } disable_raw_mode()?; execute!(term.backend_mut(), LeaveAlternateScreen)?; term.show_cursor()?; result } + +async fn handle_sbx_cmd( + line: &str, + app: &mut App, + broker: &mut Option, + pty_tx: &tokio::sync::mpsc::UnboundedSender>, + write: &mut S, + room: &fernet::Fernet, +) where + S: SinkExt + Unpin, +{ + let mut parts = line.split_whitespace(); + let _ = parts.next(); // "/sbx" + match parts.next() { + Some("launch") => { + if app.sandbox.is_some() || broker.is_some() { + app.sys("a sandbox is already running"); + return; + } + let backend = parts + .next() + .and_then(sbx::Backend::parse) + .unwrap_or(sbx::Backend::Local); + let image = parts.next().unwrap_or("ubuntu:24.04"); + let (std_tx, std_rx) = std::sync::mpsc::channel::>(); + match sbx::Sandbox::launch(backend, "house", image, SBX_ROWS, SBX_COLS, std_tx) { + Ok(sb) => { + *broker = Some(sb); + let ptx = pty_tx.clone(); + std::thread::spawn(move || { + while let Ok(b) = std_rx.recv() { + if ptx.send(b).is_err() { + break; + } + } + }); + send_frame( + write, + room, + json!({"_sbx":"status","state":"ready","backend": backend.label()}), + ) + .await; + app.sys(format!("summoning {} sandbox…", backend.label())); + } + Err(e) => app.sys(format!("sandbox launch failed: {e}")), + } + } + Some("stop") => { + if let Some(mut sb) = broker.take() { + sb.stop(); + send_frame(write, room, json!({"_sbx":"status","state":"stopped"})).await; + } else { + app.sys("you are not hosting a sandbox"); + } + } + _ => app.sys("usage: /sbx launch [local|docker|multipass] [image] | /sbx stop"), + } +} diff --git a/hh/src/main.rs b/hh/src/main.rs index 6c10744..c532489 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -7,6 +7,7 @@ mod app; mod crypto; mod net; +mod sbx; mod theme; mod ui; diff --git a/hh/src/net.rs b/hh/src/net.rs index 12057d9..11f4aba 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -112,27 +112,40 @@ fn parse_users(v: &Value) -> Vec { .collect() } -/// Decode one stored/broadcast message object into a ChatLine, or None to skip -/// (empty text, decrypt failure, or a file-transfer control frame). -fn decode_msg(room: &fernet::Fernet, m: &Value) -> Option { - let ct = m["text"].as_str()?; - if ct.is_empty() { - return None; - } +/// Classification of a decrypted message payload. +enum Decoded { + Chat(ChatLine), + Sbx(Net), + Skip, +} + +/// Decrypt + classify one stored/broadcast message object. +fn decode_msg(room: &fernet::Fernet, m: &Value, allow_sbx: bool) -> Decoded { + let ct = match m["text"].as_str() { + Some(c) if !c.is_empty() => c, + _ => return Decoded::Skip, + }; let (text, system) = match room.decrypt(ct) { Ok(pt) => { let t = String::from_utf8_lossy(&pt).to_string(); if t.starts_with("{\"_ft\":") { - return None; // file-transfer control frame — handled elsewhere (P5) + return Decoded::Skip; // file-transfer control frame (P5) + } + if t.starts_with("{\"_sbx\":") { + // Don't replay terminal history from the stored snapshot. + return if allow_sbx { + parse_sbx(&t).map(Decoded::Sbx).unwrap_or(Decoded::Skip) + } else { + Decoded::Skip + }; } (t, false) } - // Wrong room key / corrupt frame — surface, don't crash or hide silently. Err(_) => ("[unreadable — wrong room password?]".to_string(), true), }; let stamp = m["timestamp"].as_str().unwrap_or(""); let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() }; - Some(ChatLine { + Decoded::Chat(ChatLine { ts, username: m["username"].as_str().unwrap_or("?").to_string(), text, @@ -140,6 +153,20 @@ fn decode_msg(room: &fernet::Fernet, m: &Value) -> Option { }) } +/// Parse a decrypted `{"_sbx":...}` frame into a Net event. +fn parse_sbx(text: &str) -> Option { + let v: Value = serde_json::from_str(text).ok()?; + match v["_sbx"].as_str()? { + "status" => Some(Net::SbxStatus { + backend: v["backend"].as_str().unwrap_or("?").to_string(), + ready: v["state"].as_str() == Some("ready"), + }), + "data" => Some(Net::SbxData(STANDARD.decode(v["b64"].as_str()?).ok()?)), + "input" => Some(Net::SbxInput(STANDARD.decode(v["b64"].as_str()?).ok()?)), + _ => None, + } +} + /// Read websocket frames forever, forwarding decoded `Net` events to the UI. pub async fn reader(mut read: impl StreamExt> + Unpin, room: Arc, tx: UnboundedSender) { while let Some(frame) = read.next().await { @@ -158,13 +185,17 @@ pub async fn reader(mut read: impl StreamExt Some(l), + _ => None, + }) .collect(); tx.send(Net::Init { lines, users: parse_users(&v["users"]) }) } - "message" => match decode_msg(&room, &v["data"]) { - Some(l) => tx.send(Net::Message(l)), - None => Ok(()), + "message" => match decode_msg(&room, &v["data"], true) { + Decoded::Chat(l) => tx.send(Net::Message(l)), + Decoded::Sbx(ev) => tx.send(ev), + Decoded::Skip => Ok(()), }, "roster" => tx.send(Net::Roster { users: parse_users(&v["users"]), diff --git a/hh/src/sbx.rs b/hh/src/sbx.rs new file mode 100644 index 0000000..4232352 --- /dev/null +++ b/hh/src/sbx.rs @@ -0,0 +1,224 @@ +//! Sandbox backends + a PTY-backed sandbox the broker drives. +//! +//! The broker (owner's client) spawns a sandbox shell inside a PTY. Output bytes +//! are pumped out of a reader thread onto an mpsc channel; the broker encrypts +//! them with the room key and relays them to the coven as `sbx pty_data` frames. +//! Input frames (`sbx pty_input`) are written back into the PTY. The server only +//! ever sees ciphertext — identical trust model to chat/file transfer. + +use anyhow::{Context, Result}; +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; +use std::io::{Read, Write}; +use std::sync::mpsc; + +/// Which sandbox to summon. Multipass = strong isolation (default for real use), +/// Docker = fast, Local = no isolation (dev/testing only). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backend { + Local, + Docker, + Multipass, +} + +impl Backend { + pub fn parse(s: &str) -> Option { + match s { + "local" => Some(Backend::Local), + "docker" => Some(Backend::Docker), + "multipass" => Some(Backend::Multipass), + _ => None, + } + } + pub fn label(self) -> &'static str { + match self { + Backend::Local => "local-shell", + Backend::Docker => "docker", + Backend::Multipass => "multipass", + } + } +} + +/// Build the shell command that launches the sandbox for a backend. +fn command_for(backend: Backend, name: &str, image: &str) -> CommandBuilder { + match backend { + Backend::Local => { + let mut c = CommandBuilder::new("bash"); + c.arg("-i"); + c + } + Backend::Docker => { + let mut c = CommandBuilder::new("docker"); + c.args([ + "run", "--rm", "-i", "--hostname", name, "-w", "/root", image, "bash", "-i", + ]); + c + } + Backend::Multipass => { + // Assumes the instance `name` was launched separately (P3b lifecycle). + let mut c = CommandBuilder::new("multipass"); + c.args(["exec", name, "--", "bash", "-il"]); + c + } + } +} + +pub struct Sandbox { + // Held for the PTY's lifetime (dropping it closes the terminal) + resize. + #[allow(dead_code)] + master: Box, + child: Box, + writer: Box, + #[allow(dead_code)] + pub backend: Backend, +} + +impl Sandbox { + /// Spawn the backend in a PTY. A reader thread pushes raw output bytes onto + /// `out`; the caller relays them (encrypted) to the coven. + pub fn launch( + backend: Backend, + name: &str, + image: &str, + rows: u16, + cols: u16, + out: mpsc::Sender>, + ) -> Result { + let pty = native_pty_system(); + let pair = pty + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .context("openpty")?; + + let cmd = command_for(backend, name, image); + let child = pair + .slave + .spawn_command(cmd) + .with_context(|| format!("spawn {} sandbox", backend.label()))?; + drop(pair.slave); // close our handle so EOF propagates on exit + + let mut reader = pair.master.try_clone_reader().context("clone pty reader")?; + let writer = pair.master.take_writer().context("take pty writer")?; + + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if out.send(buf[..n].to_vec()).is_err() { + break; // broker gone + } + } + } + } + }); + + Ok(Sandbox { + master: pair.master, + child, + writer, + backend, + }) + } + + pub fn write_input(&mut self, data: &[u8]) -> Result<()> { + self.writer.write_all(data)?; + self.writer.flush()?; + Ok(()) + } + + #[allow(dead_code)] // wired up with PTY-resize sync (P3b) + pub fn resize(&self, rows: u16, cols: u16) -> Result<()> { + self.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .context("pty resize") + } + + pub fn stop(&mut self) { + let _ = self.child.kill(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + /// Proves the PTY pipeline: spawn a real shell, send a command, read its + /// output back off the channel. (Local backend — no container needed.) + #[test] + fn local_shell_pty_roundtrip() { + let (tx, rx) = mpsc::channel(); + let mut sb = Sandbox::launch(Backend::Local, "test", "ubuntu:24.04", 24, 80, tx) + .expect("launch local shell"); + sb.write_input(b"echo HELLO_PTY_42\n").unwrap(); + + let mut acc = String::new(); + let deadline = Instant::now() + Duration::from_secs(4); + while Instant::now() < deadline { + if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(200)) { + acc.push_str(&String::from_utf8_lossy(&chunk)); + if acc.contains("HELLO_PTY_42") { + break; + } + } + } + sb.stop(); + assert!( + acc.contains("HELLO_PTY_42"), + "pty output missing marker; got: {acc:?}" + ); + } +} + +#[cfg(test)] +mod relay_tests { + use super::*; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + + /// End-to-end (headless) sandbox relay: launch a local sandbox, run a + /// command, encode the PTY output exactly as the broker sends it over the + /// encrypted channel (`{"_sbx":"data","b64":...}`), then decode it back the + /// way a remote client does and feed it to a vt100 screen — asserting the + /// command's output lands on the rendered terminal. + #[test] + fn sandbox_output_reaches_a_vt100_screen_via_frames() { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + + let (tx, rx) = mpsc::channel(); + let mut sb = Sandbox::launch(Backend::Local, "test", "ubuntu:24.04", 24, 80, tx) + .expect("launch"); + sb.write_input(b"echo RELAY_MARKER_7\n").unwrap(); + + // Remote client side: a vt100 parser fed from decoded data frames. + let mut screen = vt100::Parser::new(24, 80, 0); + let deadline = Instant::now() + Duration::from_secs(4); + let mut hit = false; + while Instant::now() < deadline { + if let Ok(chunk) = rx.recv_timeout(Duration::from_millis(200)) { + // broker: encode → (server relay) → client: decode + let frame = serde_json::json!({"_sbx":"data","b64": STANDARD.encode(&chunk)}); + let b64 = frame["b64"].as_str().unwrap(); + let decoded = STANDARD.decode(b64).unwrap(); + screen.process(&decoded); + if screen.screen().contents().contains("RELAY_MARKER_7") { + hit = true; + break; + } + } + } + sb.stop(); + assert!(hit, "command output never reached the rendered terminal"); + } +} diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 8fc8d85..638829c 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -18,13 +18,48 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { draw_top(f, rows[0], app, theme); + // When a sandbox is live, split the body: chat+roster on top, PTY below. + let (chat_area, sbx_area) = if app.sandbox.is_some() { + let split = Layout::vertical([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(rows[1]); + (split[0], Some(split[1])) + } else { + (rows[1], None) + }; + let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)]) - .split(rows[1]); + .split(chat_area); draw_chat(f, body[0], app, theme); draw_roster(f, body[1], app, theme); + if let Some(area) = sbx_area { + draw_sandbox(f, area, app, theme); + } draw_input(f, rows[2], app, theme); } +fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { + let Some(sv) = &app.sandbox else { return }; + let screen = sv.parser.screen(); + let (_rows, cols) = screen.size(); + let lines: Vec = screen + .rows(0, cols) + .map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title)))) + .collect(); + let drive = if app.driving { + " · DRIVING (esc to release)" + } else { + " · F2 to drive" + }; + let title = format!(" sandbox · {}{} ", sv.backend, drive); + let border = if app.driving { theme.accent } else { theme.border }; + let pane = Paragraph::new(lines).block( + Block::bordered() + .border_style(Style::default().fg(border)) + .title(Span::styled(title, Style::default().fg(theme.title))), + ); + f.render_widget(pane, area); +} + fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) { let cap = if app.capacity > 0 { app.capacity } else { app.users.len() }; let status = if app.connected { "🔒 e2e" } else { "✖ closed" };