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:
+186
-17
@@ -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<User>, capacity: usize },
|
||||
Joined(String),
|
||||
Left(String),
|
||||
SbxStatus { backend: String, ready: bool },
|
||||
SbxData(Vec<u8>),
|
||||
SbxInput(Vec<u8>),
|
||||
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<ChatLine>,
|
||||
@@ -48,6 +64,8 @@ pub struct App {
|
||||
pub capacity: usize,
|
||||
pub input: String,
|
||||
pub connected: bool,
|
||||
pub sandbox: Option<SbxView>,
|
||||
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<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<()> {
|
||||
let ws = net::connect(&session).await?;
|
||||
let (mut write, read) = ws.split();
|
||||
let (tx, mut rx) = unbounded_channel::<Net>();
|
||||
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()?;
|
||||
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<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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
mod app;
|
||||
mod crypto;
|
||||
mod net;
|
||||
mod sbx;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
|
||||
+45
-14
@@ -112,27 +112,40 @@ fn parse_users(v: &Value) -> Vec<User> {
|
||||
.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<ChatLine> {
|
||||
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<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.
|
||||
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 {
|
||||
@@ -158,13 +185,17 @@ pub async fn reader(mut read: impl StreamExt<Item = Result<WsMsg, tokio_tungsten
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|m| decode_msg(&room, m))
|
||||
.filter_map(|m| match decode_msg(&room, m, false) {
|
||||
Decoded::Chat(l) => 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"]),
|
||||
|
||||
+224
@@ -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");
|
||||
}
|
||||
}
|
||||
+36
-1
@@ -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<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) {
|
||||
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
|
||||
let status = if app.connected { "🔒 e2e" } else { "✖ closed" };
|
||||
|
||||
Reference in New Issue
Block a user