feat(hh): P3 — summonable sandbox + shared PTY ⛧

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 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-05-30 14:26:14 -07:00
parent d8acadd68b
commit 232a00cc9e
7 changed files with 707 additions and 38 deletions

217
hh/Cargo.lock generated
View File

@ -64,6 +64,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -104,6 +110,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.11.1"
@ -264,7 +276,7 @@ version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"crossterm_winapi", "crossterm_winapi",
"futures-core", "futures-core",
"mio", "mio",
@ -356,6 +368,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@ -397,6 +415,17 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -551,6 +580,7 @@ dependencies = [
"hkdf", "hkdf",
"num-bigint", "num-bigint",
"num-traits", "num-traits",
"portable-pty",
"rand 0.8.6", "rand 0.8.6",
"ratatui", "ratatui",
"reqwest", "reqwest",
@ -563,6 +593,7 @@ dependencies = [
"toml", "toml",
"tungstenite", "tungstenite",
"url", "url",
"vt100",
] ]
[[package]] [[package]]
@ -851,6 +882,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "ioctl-rs"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@ -900,6 +940,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@ -954,6 +1000,15 @@ version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memoffset"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@ -966,6 +1021,20 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@ -1012,7 +1081,7 @@ version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"cfg-if", "cfg-if",
"foreign-types", "foreign-types",
"libc", "libc",
@ -1084,12 +1153,39 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" 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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@ -1252,7 +1348,7 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"cassowary", "cassowary",
"compact_str", "compact_str",
"crossterm", "crossterm",
@ -1274,7 +1370,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
] ]
[[package]] [[package]]
@ -1343,7 +1439,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@ -1470,6 +1566,48 @@ dependencies = [
"serde", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@ -1492,6 +1630,22 @@ dependencies = [
"digest", "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]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@ -1628,6 +1782,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "termios"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@ -1807,7 +1970,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags", "bitflags 2.11.1",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@ -1966,6 +2129,39 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@ -2276,6 +2472,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"

View File

@ -26,6 +26,10 @@ tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
rustls = "0.23" rustls = "0.23"
url = "2" url = "2"
# sandbox (P3): PTY + terminal emulation
portable-pty = "0.8"
vt100 = "0.15"
# async + tui # async + tui
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }

View File

@ -1,21 +1,28 @@
//! TUI application state, network event model, and the async run loop. //! TUI application state, network event model, and the async run loop.
use crate::net::{self, Session}; use crate::net::{self, Session};
use crate::sbx;
use crate::theme::Theme; use crate::theme::Theme;
use crate::ui; use crate::ui;
use anyhow::Result; use anyhow::Result;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
}; };
use crossterm::execute;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use serde_json::json;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
use tokio_tungstenite::tungstenite::Message as WsMsg; use tokio_tungstenite::tungstenite::Message as WsMsg;
pub const SBX_ROWS: u16 = 24;
pub const SBX_COLS: u16 = 80;
/// One rendered chat row. /// One rendered chat row.
#[derive(Clone)] #[derive(Clone)]
pub struct ChatLine { pub struct ChatLine {
@ -38,9 +45,18 @@ pub enum Net {
Roster { users: Vec<User>, capacity: usize }, Roster { users: Vec<User>, capacity: usize },
Joined(String), Joined(String),
Left(String), Left(String),
SbxStatus { backend: String, ready: bool },
SbxData(Vec<u8>),
SbxInput(Vec<u8>),
Closed, 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 struct App {
pub me: String, pub me: String,
pub lines: Vec<ChatLine>, pub lines: Vec<ChatLine>,
@ -48,6 +64,8 @@ pub struct App {
pub capacity: usize, pub capacity: usize,
pub input: String, pub input: String,
pub connected: bool, pub connected: bool,
pub sandbox: Option<SbxView>,
pub driving: bool,
} }
impl App { impl App {
@ -59,6 +77,8 @@ impl App {
capacity: 0, capacity: 0,
input: String::new(), input: String::new(),
connected: false, connected: false,
sandbox: None,
driving: false,
} }
} }
@ -78,6 +98,7 @@ impl App {
self.users = users; self.users = users;
self.connected = true; self.connected = true;
self.sys(format!("joined as {}", self.me)); 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::Message(l) => self.lines.push(l),
Net::Roster { users, capacity } => { Net::Roster { users, capacity } => {
@ -91,6 +112,26 @@ impl App {
self.sys(format!("{name} left")); 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 => { Net::Closed => {
self.connected = false; self.connected = false;
self.sys("connection closed"); 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<Vec<u8>> {
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<S>(write: &mut S, room: &fernet::Fernet, value: serde_json::Value)
where
S: SinkExt<WsMsg> + 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<()> { pub async fn run(session: Session, theme: Theme) -> Result<()> {
let ws = net::connect(&session).await?; let ws = net::connect(&session).await?;
let (mut write, read) = ws.split(); let (mut write, read) = ws.split();
let (tx, mut rx) = unbounded_channel::<Net>(); let (tx, mut rx) = unbounded_channel::<Net>();
tokio::spawn(net::reader(read, session.room.clone(), tx)); 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<Vec<u8>>) = unbounded_channel();
let mut broker: Option<sbx::Sandbox> = None;
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = std::io::stdout(); let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?; 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 app = App::new(session.username.clone());
let mut events = EventStream::new(); 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 { let result = loop {
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) { if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
@ -124,40 +199,134 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
maybe = events.next() => { maybe = events.next() => {
match maybe { match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
match (k.modifiers, k.code) { // Global quit.
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break Ok(()), if k.modifiers.contains(KeyModifiers::CONTROL)
(_, KeyCode::Esc) => break Ok(()), && matches!(k.code, KeyCode::Char('q')) {
(_, KeyCode::Enter) => { 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(); let line = app.input.trim().to_string();
app.input.clear(); app.input.clear();
if !line.is_empty() && app.connected { 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()); let ct = session.room.encrypt(line.as_bytes());
if write.send(WsMsg::Text(ct)).await.is_err() { if write.send(WsMsg::Text(ct)).await.is_err() {
app.connected = false; app.connected = false;
} }
} }
} }
(_, KeyCode::Backspace) => { app.input.pop(); } KeyCode::Backspace => { app.input.pop(); }
(_, KeyCode::Char(c)) => app.input.push(c), KeyCode::Char(c) => app.input.push(c),
_ => {} _ => {}
} }
} }
}
Some(Err(e)) => break Err(e.into()), Some(Err(e)) => break Err(e.into()),
_ => {} _ => {}
} }
} }
net = rx.recv() => { net = rx.recv() => {
match net { match net {
Some(Net::SbxInput(b)) => {
if let Some(sb) = &mut broker { let _ = sb.write_input(&b); }
}
Some(n) => app.apply(n), Some(n) => app.apply(n),
None => break Ok(()), 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() => {} _ = tick.tick() => {}
} }
}; };
if let Some(mut sb) = broker.take() {
sb.stop();
}
disable_raw_mode()?; disable_raw_mode()?;
execute!(term.backend_mut(), LeaveAlternateScreen)?; execute!(term.backend_mut(), LeaveAlternateScreen)?;
term.show_cursor()?; term.show_cursor()?;
result result
} }
async fn handle_sbx_cmd<S>(
line: &str,
app: &mut App,
broker: &mut Option<sbx::Sandbox>,
pty_tx: &tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
write: &mut S,
room: &fernet::Fernet,
) where
S: SinkExt<WsMsg> + 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::<Vec<u8>>();
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"),
}
}

View File

@ -7,6 +7,7 @@
mod app; mod app;
mod crypto; mod crypto;
mod net; mod net;
mod sbx;
mod theme; mod theme;
mod ui; mod ui;

View File

@ -112,27 +112,40 @@ fn parse_users(v: &Value) -> Vec<User> {
.collect() .collect()
} }
/// Decode one stored/broadcast message object into a ChatLine, or None to skip /// Classification of a decrypted message payload.
/// (empty text, decrypt failure, or a file-transfer control frame). enum Decoded {
fn decode_msg(room: &fernet::Fernet, m: &Value) -> Option<ChatLine> { Chat(ChatLine),
let ct = m["text"].as_str()?; Sbx(Net),
if ct.is_empty() { Skip,
return None;
} }
/// 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) { let (text, system) = match room.decrypt(ct) {
Ok(pt) => { Ok(pt) => {
let t = String::from_utf8_lossy(&pt).to_string(); let t = String::from_utf8_lossy(&pt).to_string();
if t.starts_with("{\"_ft\":") { 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) (t, false)
} }
// Wrong room key / corrupt frame — surface, don't crash or hide silently.
Err(_) => ("[unreadable — wrong room password?]".to_string(), true), Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
}; };
let stamp = m["timestamp"].as_str().unwrap_or(""); let stamp = m["timestamp"].as_str().unwrap_or("");
let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() }; let ts = if stamp.len() >= 19 { stamp[11..19].to_string() } else { String::new() };
Some(ChatLine { Decoded::Chat(ChatLine {
ts, ts,
username: m["username"].as_str().unwrap_or("?").to_string(), username: m["username"].as_str().unwrap_or("?").to_string(),
text, text,
@ -140,6 +153,20 @@ fn decode_msg(room: &fernet::Fernet, m: &Value) -> Option<ChatLine> {
}) })
} }
/// Parse a decrypted `{"_sbx":...}` frame into a Net event.
fn parse_sbx(text: &str) -> Option<Net> {
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. /// Read websocket frames forever, forwarding decoded `Net` events to the UI.
pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) { pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungstenite::tungstenite::Error>> + Unpin, room: Arc<fernet::Fernet>, tx: UnboundedSender<Net>) {
while let Some(frame) = read.next().await { while let Some(frame) = read.next().await {
@ -158,13 +185,17 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
.as_array() .as_array()
.into_iter() .into_iter()
.flatten() .flatten()
.filter_map(|m| decode_msg(&room, m)) .filter_map(|m| match decode_msg(&room, m, false) {
Decoded::Chat(l) => Some(l),
_ => None,
})
.collect(); .collect();
tx.send(Net::Init { lines, users: parse_users(&v["users"]) }) tx.send(Net::Init { lines, users: parse_users(&v["users"]) })
} }
"message" => match decode_msg(&room, &v["data"]) { "message" => match decode_msg(&room, &v["data"], true) {
Some(l) => tx.send(Net::Message(l)), Decoded::Chat(l) => tx.send(Net::Message(l)),
None => Ok(()), Decoded::Sbx(ev) => tx.send(ev),
Decoded::Skip => Ok(()),
}, },
"roster" => tx.send(Net::Roster { "roster" => tx.send(Net::Roster {
users: parse_users(&v["users"]), users: parse_users(&v["users"]),

224
hh/src/sbx.rs Normal file
View File

@ -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<Backend> {
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<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
writer: Box<dyn Write + Send>,
#[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<Vec<u8>>,
) -> Result<Sandbox> {
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");
}
}

View File

@ -18,13 +18,48 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
draw_top(f, rows[0], app, theme); draw_top(f, rows[0], app, theme);
let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)]) // 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(rows[1]);
(split[0], Some(split[1]))
} else {
(rows[1], None)
};
let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)])
.split(chat_area);
draw_chat(f, body[0], app, theme); draw_chat(f, body[0], app, theme);
draw_roster(f, body[1], 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); 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<Line> = 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) { 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 cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
let status = if app.connected { "🔒 e2e" } else { "✖ closed" }; let status = if app.connected { "🔒 e2e" } else { "✖ closed" };