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
+45 -14
View File
@@ -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"]),