Files
hack-house/hh/src/app.rs
T
leetcrypt c09b428718
CI / rust client (hh) (macos-latest) (push) Has been cancelled
CI / rust client (hh) (ubuntu-latest) (push) Has been cancelled
CI / rust coverage (push) Has been cancelled
CI / python server (3.10) (push) Has been cancelled
CI / python server (3.11) (push) Has been cancelled
CI / python server (3.12) (push) Has been cancelled
CI / headless e2e smoke (push) Has been cancelled
CI / dependency audit (push) Has been cancelled
CI / secret scanning (push) Has been cancelled
feat(ai): /grant ai grants drive to every AI agent at once
So the owner never has to name each model. Tracks an ai_agents set
(populated from `_ai` typing/stream frames and the "(ai) online" announce,
pruned on leave); `/grant ai` intersects it with the live roster and grants
all in one ACL broadcast. Help text gains a /grant ai row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-09 17:02:45 -07:00

3474 lines
163 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TUI application state, network event model, and the async run loop.
use crate::ft;
use crate::layout::{Dir, Layout, Resize};
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::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
KeyModifiers, MouseButton, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use futures_util::{SinkExt, StreamExt};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio_tungstenite::tungstenite::Message as WsMsg;
const SBX_NAME: &str = "hack-house";
#[derive(Clone)]
pub struct ChatLine {
pub ts: String,
pub username: String,
pub text: String,
pub system: bool,
}
/// A power a user currently holds in the room. Badges stack: a user can be the
/// `Host` *and* a `Sudoer` *and* a `Driver` at once. `Member` is the floor —
/// returned only when none of the others apply. The order of the variants is
/// the order badges are painted (host first).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Role {
Host,
Sudoer,
Driver,
Member,
}
#[derive(Clone)]
pub struct User {
pub user_id: String,
pub username: String,
}
/// An in-progress incoming transfer we accepted. Chunks stream straight to a
/// disk-backed `Sink` (created lazily on the first chunk) so a multi-GB payload
/// never sits in RAM.
struct Transfer {
meta: ft::Offer,
sink: Option<ft::Sink>,
accepted: bool,
}
/// An outgoing transfer awaiting / serving an accept. The payload is streamed
/// from `src` on disk, not held in memory.
struct ActiveSend {
id: String,
src: std::path::PathBuf,
/// `src` is a temp tar we built for a directory — delete it after sending.
temp: bool,
sending: bool,
}
/// Decoded events arriving from the websocket reader task.
pub enum Net {
Init {
lines: Vec<ChatLine>,
users: Vec<User>,
},
Message(ChatLine),
Roster {
users: Vec<User>,
capacity: usize,
},
Joined(String),
Left(String),
SbxStatus {
backend: String,
ready: bool,
rows: u16,
cols: u16,
},
SbxResize {
rows: u16,
cols: u16,
},
SbxData(Vec<u8>),
SbxInput {
from: String,
bytes: Vec<u8>,
},
Perm {
owner: String,
drivers: Vec<String>,
sudoers: Vec<String>,
},
Ft(ft::Ft),
/// An AI agent is generating a reply (`on`) or has finished (`!on`).
AiTyping {
name: String,
on: bool,
},
/// Incremental reply text from a streaming AI agent. `text` is the reply so
/// far (cumulative); `done` clears the live preview (the final, persisted
/// chat message arrives separately as a normal `Message`).
AiStream {
name: String,
text: String,
done: bool,
},
/// A local system notice produced off-thread (e.g. async Ollama probe).
Sys(String),
Err(String),
/// An outgoing `/send` finished hashing off-thread and is staged for
/// streaming — the run loop records it in `active_send` so a peer's `/accept`
/// can start the chunk stream. The offer frame is emitted by the same task.
SendReady {
id: String,
src: std::path::PathBuf,
temp: bool,
name: String,
size: u64,
to: Option<String>,
},
/// Relayed to the room when a member opens a shared VirtualBox VM locally, so
/// the *other* party knows the appliance is live and can open their own copy.
/// `by` is the server-authenticated launcher; the receiver skips its own echo.
VmOpened {
by: String,
vm: String,
},
Closed,
}
pub struct SbxView {
pub parser: vt100::Parser,
pub backend: String,
}
/// The arrow-navigable VirtualBox VM picker, opened by a bare `/sbx vbox`
/// (or `/sbx gui`). Holds the locally-registered VM names and the highlighted
/// row; Enter/Tab fills `/sbx vbox gui <vm>` into the input, Esc dismisses.
#[derive(Clone)]
pub struct VboxPicker {
pub vms: Vec<String>,
pub selected: usize,
}
/// A resizable region of the window, for interactive layout editing. Selected by
/// clicking it (or cycling with F5); once selected, arrow keys resize it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Pane {
Chat,
Roster,
Terminal,
/// The message/compose box at the bottom. Unlike the other three it lives
/// outside the BSP body tree (it's the frame's fixed bottom row), so its only
/// adjustable axis is height — grow it to read a long/wrapped message.
Input,
}
/// Launch parameters captured when `/sbx launch` needs root but sudo isn't
/// cached. Held aside while the masked password modal collects the secret; the
/// launch fires (with the password) once it's submitted.
pub struct PendingSudoLaunch {
pub backend: sbx::Backend,
pub image: String,
pub members: Vec<String>,
pub rows: u16,
pub cols: u16,
pub start_daemon: bool,
pub install_first: bool,
}
/// Launch parameters captured when installing VirtualBox needs root but sudo
/// isn't cached. Held aside while the masked password modal collects the secret;
/// the install + VM boot fires (with the password) once it's submitted. Mirrors
/// `PendingSudoLaunch` for the VirtualBox GUI path.
pub struct PendingVboxInstall {
pub vm: String,
pub conflicts: Vec<sbx::VtxHolder>,
pub needs_install: bool,
pub needs_import: bool,
pub room: Arc<fernet::Fernet>,
}
/// What a submitted sudo password authorizes. The masked modal is shared; this
/// records which privileged action to fire once the password is entered.
pub enum PendingPrivileged {
/// Launch a container sandbox that needs root (binary install / daemon start).
Launch(PendingSudoLaunch),
/// Install VirtualBox (apt needs root), then boot the requested VM's GUI.
VboxInstall(PendingVboxInstall),
}
/// A local, masked sudo-password prompt. The typed password lives ONLY here —
/// it never enters `App::input` (chat), a `ChatLine`, the PTY stream, or any
/// outbound frame — until it's handed to `sudo -S` for the pending action.
/// Deliberately no `Debug` derive so the secret can't be logged by accident.
pub struct SudoPrompt {
pub password: String,
pub pending: PendingPrivileged,
}
pub struct App {
pub me: String,
pub lines: Vec<ChatLine>,
pub users: Vec<User>,
pub capacity: usize,
pub input: String,
pub connected: bool,
pub sandbox: Option<SbxView>,
pub driving: bool,
pub owner: Option<String>,
pub drivers: std::collections::HashSet<String>,
/// Members whose VM unix account has sudo (superuser). Always includes owner.
pub sudoers: std::collections::HashSet<String>,
pub pending_offer: Option<ft::Offer>,
/// Open VM picker (arrow-navigable list of local VirtualBox VMs), or None.
pub vbox_picker: Option<VboxPicker>,
transfers: HashMap<String, Transfer>,
/// Chat scrollback: lines scrolled up from the live bottom (0 = following).
pub chat_scroll: usize,
/// Sandbox terminal scrollback: rows scrolled up from the bottom.
pub sbx_scroll: usize,
/// Whether the help overlay is showing.
pub show_help: bool,
/// Vertical scroll offset (rows) into the help overlay when it doesn't fit.
pub help_scroll: u16,
/// Index of the currently highlighted help cluster (up/down moves it).
pub help_selected: usize,
/// Per-cluster expand/collapse state (left/right/Enter toggles); sized to the
/// cluster count when the overlay opens.
pub help_expanded: Vec<bool>,
/// A reconnect handshake is in flight (Ctrl-R after a disconnect).
pub reconnecting: bool,
/// Transient error shown as a popup over the clergy (cleared on next keypress).
pub error: Option<String>,
/// The room password this client authenticated with (shown by `/pw`).
pub password: String,
/// AI agents currently generating a reply — drives the "thinking" spinner.
pub ai_typing: std::collections::HashSet<String>,
/// Every room member we've identified as an AI agent (via its `_ai` frames
/// or its `(ai) online` announce). Lets `/grant ai` grant drive to all of
/// them at once; pruned when a member leaves.
pub ai_agents: std::collections::HashSet<String>,
/// Live, in-progress reply text per streaming agent, shown as a transient
/// preview bubble until the final message lands. Keyed by agent name.
pub ai_stream: std::collections::HashMap<String, String>,
/// Monotonic tick counter used to animate the AI spinner.
pub spin: usize,
/// When set, agents we summon are auto-granted sandbox drive on each launch
/// (`/ai start <name> allow`). Re-applied in the broker Ready handler, since
/// launching a sandbox resets the ACL back to just the owner.
pub agent_sbx_allow: bool,
/// Display name the summoned agent joined under — derived from its model or
/// profile at `/ai start` (e.g. "qwen2.5:3b", model name + parameter size).
/// Tracked so the broker Ready handler can re-grant its drive and `/ai stop`
/// can revoke the right ACL entry.
pub agent_name: Option<String>,
/// Window layout: chat/terminal split, roster width, fullscreen zoom. Read
/// by `ui::draw` and `sbx_dims`; mutated by F4/F5 + interactive editing.
pub layout: Layout,
/// Interactive layout editing: the pane currently selected for resizing
/// (click it or cycle with F5), or None when not editing. When set, arrow
/// keys resize this pane instead of scrolling.
pub focused_pane: Option<Pane>,
/// Active local sudo-password prompt (None unless `/sbx launch` needs root
/// and creds aren't cached). While `Some`, keystrokes feed this masked
/// buffer instead of chat — the secret never leaves the client.
pub sudo_prompt: Option<SudoPrompt>,
}
impl App {
fn new(me: String) -> Self {
Self {
me,
lines: Vec::new(),
users: Vec::new(),
capacity: 0,
input: String::new(),
connected: false,
sandbox: None,
driving: false,
owner: None,
drivers: std::collections::HashSet::new(),
sudoers: std::collections::HashSet::new(),
pending_offer: None,
vbox_picker: None,
transfers: HashMap::new(),
chat_scroll: 0,
sbx_scroll: 0,
show_help: false,
help_scroll: 0,
help_selected: 0,
help_expanded: Vec::new(),
reconnecting: false,
error: None,
password: String::new(),
ai_typing: std::collections::HashSet::new(),
ai_agents: std::collections::HashSet::new(),
ai_stream: std::collections::HashMap::new(),
spin: 0,
agent_sbx_allow: false,
agent_name: None,
layout: Layout::default(),
focused_pane: None,
sudo_prompt: None,
}
}
/// Length of the in-progress sudo password (for the masked modal to render
/// `•`s), or `None` when no prompt is open. Exposes only the length — never
/// the secret itself — so `ui` can draw it without touching the bytes.
pub fn sudo_prompt_len(&self) -> Option<usize> {
self.sudo_prompt.as_ref().map(|p| p.password.chars().count())
}
/// Cycle the interactive-edit selection: Chat → Terminal → Roster → Input →
/// off. Bound to F5; clicking a pane jumps straight to it instead. The
/// Terminal step is skipped when no sandbox is up (nothing to resize there);
/// the Input bar is always a stop (height-only).
fn cycle_focus(&mut self) {
// Cycle over the panes the layout is actually painting (chat → terminal →
// roster → input), then off. Skips the terminal with no sandbox and the
// roster when it's hidden, so every stop is something you can resize.
let panes = self.layout.present_panes(self.sandbox.is_some());
self.focused_pane = match self.focused_pane {
None => panes.first().copied(),
Some(cur) => match panes.iter().position(|p| *p == cur) {
Some(i) => panes.get(i + 1).copied(),
None => panes.first().copied(),
},
};
}
/// Append a chat line. Holds the viewport steady if scrolled up, and caps
/// the in-memory backlog.
fn push_line(&mut self, l: ChatLine) {
self.lines.push(l);
if self.chat_scroll > 0 {
self.chat_scroll += 1;
}
const CAP: usize = 4000;
if self.lines.len() > CAP {
let drop = self.lines.len() - CAP;
self.lines.drain(0..drop);
self.chat_scroll = self.chat_scroll.min(self.lines.len().saturating_sub(1));
}
}
pub fn is_owner(&self) -> bool {
self.owner.as_deref() == Some(self.me.as_str())
}
pub fn can_drive(&self) -> bool {
self.drivers.contains(&self.me)
}
/// The room host — whoever opened the house. The relay returns the roster in
/// join order (its session store is an insertion-ordered map), and every
/// client is served the *same* ordered roster, so "first occupant" is a
/// stable, consistent host across all peers without any extra protocol.
pub fn host(&self) -> Option<&str> {
self.users.first().map(|u| u.username.as_str())
}
/// Every role `name` currently holds, additive and in paint order. Reads the
/// exact same `sudoers`/`drivers` sets the broker uses to gate shell input,
/// so a rendered badge can never claim a power the room won't actually
/// enforce. Always returns at least `Member`.
pub fn roles_of(&self, name: &str) -> Vec<Role> {
let mut roles = Vec::new();
if self.host() == Some(name) {
roles.push(Role::Host);
}
if self.sudoers.contains(name) {
roles.push(Role::Sudoer);
}
if self.drivers.contains(name) {
roles.push(Role::Driver);
}
if roles.is_empty() {
roles.push(Role::Member);
}
roles
}
fn sys(&mut self, text: impl Into<String>) {
self.push_line(ChatLine {
ts: String::new(),
username: String::new(),
text: text.into(),
system: true,
});
}
/// Open the help overlay fresh: first cluster highlighted, scrolled to top,
/// all clusters collapsed (the empty expand vec renders as all-collapsed and
/// is resized to the cluster count on the first navigation key).
fn open_help(&mut self) {
self.show_help = true;
self.help_scroll = 0;
self.help_selected = 0;
self.help_expanded.clear();
}
/// Surface an error: kept in chat scrollback for history AND shown as a
/// popup over the clergy so it can't bleed onto / be overwritten at the
/// input box. Dismissed by the next keypress.
fn err(&mut self, text: impl Into<String>) {
let t = text.into();
self.sys(format!("✖ {t}"));
self.error = Some(t);
}
fn apply(&mut self, n: Net) {
match n {
Net::Init { lines, users } => {
self.lines = lines;
self.users = users;
self.connected = true;
self.chat_scroll = 0;
self.sys(format!("joined as {} †", self.me));
self.sys("/sbx <docker|podman|multipass|vbox|local> · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /pw show password · /help full command list · PgUp/PgDn scroll chat · ctrl-q quit");
}
Net::Message(l) => {
// An agent announces itself with "<name> (ai) online …" — record
// it as an AI member so `/grant ai` can reach it before it acts.
if !l.system && l.text.starts_with(&format!("{} (ai) ", l.username)) {
self.ai_agents.insert(l.username.clone());
}
self.push_line(l);
}
Net::Roster { users, capacity } => {
self.users = users;
self.capacity = capacity;
}
Net::Joined(name) => self.sys(format!("{name} entered the house")),
Net::Left(uid) => {
if let Some(p) = self.users.iter().position(|u| u.user_id == uid) {
let name = self.users.remove(p).username;
self.ai_typing.remove(&name); // a departed agent isn't thinking
self.ai_agents.remove(&name); // …nor an AI member any more
self.ai_stream.remove(&name); // …nor streaming a reply
self.sys(format!("{name} left"));
}
}
Net::AiTyping { name, on } => {
self.ai_agents.insert(name.clone()); // an `_ai` frame ⇒ AI member
if on {
self.ai_typing.insert(name);
} else {
self.ai_typing.remove(&name);
}
}
Net::AiStream { name, text, done } => {
self.ai_agents.insert(name.clone()); // an `_ai` frame ⇒ AI member
if done {
self.ai_stream.remove(&name);
} else {
// Streaming has started → drop the bare "thinking" spinner;
// the live preview now signals the agent is working.
self.ai_typing.remove(&name);
self.ai_stream.insert(name, text);
}
}
Net::SbxStatus {
backend,
ready,
rows,
cols,
} => {
if ready {
// Converge on the shared shell. If we already track this
// sandbox, just match its size — recreating the parser would
// wipe scrollback and re-announce on every re-broadcast. The
// owner re-sends `status:ready` whenever a member (re)joins, so
// this MUST be idempotent for everyone already in the room; only
// a genuinely new sandbox (none yet) is created and announced.
match &mut self.sandbox {
Some(v) => {
v.parser.set_size(rows.max(1), cols.max(1));
v.backend = backend.clone();
}
None => {
self.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
backend: backend.clone(),
});
self.sys(format!("† sandbox summoned ({backend}) — F2 to drive"));
}
}
} else {
self.sandbox = None;
self.driving = false;
self.sbx_scroll = 0;
self.owner = None;
self.drivers.clear();
self.sudoers.clear();
self.sys("† sandbox dismissed");
}
}
Net::SbxResize { rows, cols } => {
if let Some(v) = &mut self.sandbox {
v.parser.set_size(rows.max(1), cols.max(1));
}
}
Net::SbxData(bytes) => {
if let Some(v) = &mut self.sandbox {
v.parser.process(&bytes);
}
}
Net::SbxInput { .. } => {} // broker enforces + writes in the run loop
Net::Perm {
owner,
drivers,
sudoers,
} => {
let new: std::collections::HashSet<String> = drivers.into_iter().collect();
let sudo: std::collections::HashSet<String> = sudoers.into_iter().collect();
if !owner.is_empty() && self.owner.as_deref() != Some(owner.as_str()) {
self.sys(format!("† {owner} is the superuser (sandbox owner)"));
}
if new.contains(&self.me)
&& !self.drivers.contains(&self.me)
&& self.owner.is_some()
{
self.sys("† you were granted drive (F2 to take the shell)");
} else if !new.contains(&self.me) && self.drivers.contains(&self.me) {
self.driving = false;
self.sys("† your drive permission was revoked");
}
if sudo.contains(&self.me)
&& !self.sudoers.contains(&self.me)
&& self.owner.is_some()
{
self.sys("† you were granted sudo (superuser) in the VM");
}
self.owner = Some(owner).filter(|o| !o.is_empty());
self.drivers = new;
self.sudoers = sudo;
}
Net::Ft(_) => {} // handled in the run loop (needs out channel + disk)
Net::SendReady { .. } => {} // handled in the run loop (stages active_send)
Net::Sys(t) => self.sys(t),
Net::Err(t) => self.err(t),
Net::VmOpened { by, vm } => {
// Skip our own echo — we already saw the local "launched" line.
if by != self.me {
self.sys(format!(
"† {by} opened {vm} locally — `/sbx gui {vm}` to open your own copy"
));
}
}
Net::Closed => {
self.connected = false;
self.sys("connection closed — press Ctrl-R to reconnect");
}
}
}
}
/// Human label for a resizable pane (used in layout-editing hints).
fn pane_label(p: Pane) -> &'static str {
match p {
Pane::Chat => "chat",
Pane::Roster => "roster",
Pane::Terminal => "terminal",
Pane::Input => "input",
}
}
/// Join saved-preset names for display, or "none" when there are none.
fn once_or_none(names: Vec<String>) -> String {
if names.is_empty() {
"none".to_string()
} else {
names.join(" · ")
}
}
/// PTY grid (rows, cols) for the sandbox terminal, derived from the Terminal
/// leaf's actual rectangle in the layout tree — so it tracks **both** axes (the
/// old height-only `pty_pct` couldn't express width changes). Matches the split
/// `ui::draw` paints, keeping the local PTY and the room in lock-step. Subtracts
/// the pane border (2 each way) and floors at 1. Falls back to the full body
/// when no Terminal leaf is laid out (e.g. transiently before the tree rebuilds).
fn sbx_grid(term_w: u16, term_h: u16, layout: &Layout) -> (u16, u16) {
use ratatui::layout::Rect;
// Mirror ui::draw's frame split: 1-line top bar, body, then the input bar
// (now a resizable height, not a fixed 3 lines).
let body = Rect {
x: 0,
y: 1,
width: term_w,
height: term_h.saturating_sub(1 + layout.input_height()),
};
let r = layout
.rect_of(body, Pane::Terminal, true)
.unwrap_or(body);
(
r.height.saturating_sub(2).max(1),
r.width.saturating_sub(2).max(1),
)
}
/// One page of sandbox scrollback = the visible grid height (defaults to 10 if
/// no sandbox is up). The run loop clamps `sbx_scroll` to the grid height anyway.
fn sbx_page(app: &App) -> usize {
app.sandbox
.as_ref()
.map(|v| v.parser.screen().size().0 as usize)
.unwrap_or(10)
.max(1)
}
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']),
// Esc must reach the shell (vim/less/etc. depend on it). Drive is released
// with F2, not Esc, so this never collides with leaving the shell.
KeyCode::Esc => Some(vec![0x1b]),
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,
}
}
/// Queue an encrypted JSON frame for transmission (drained by the run loop).
fn send_frame(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, value: serde_json::Value) {
let _ = out.send(WsMsg::Text(room.encrypt(value.to_string().as_bytes())));
}
/// Read `path` and broadcast a file/dir offer. `to = Some(user)` targets one
/// member (only they're prompted); `to = None` offers to the whole room. The
/// payload is staged in `active_send` and streamed once an /accept arrives.
fn offer_payload(
app: &mut App,
send_seq: &mut u64,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
app_tx: &UnboundedSender<Net>,
path: &str,
to: Option<&str>,
) {
*send_seq += 1;
let id = format!("{}-{}", app.me, send_seq);
let path = path.to_string();
let to = to.map(str::to_string);
let out = out_tx.clone();
let room = room.clone();
let atx = app_tx.clone();
// Hashing (and tarring a directory) walks every byte — do it off the UI
// thread so a multi-GB payload doesn't freeze the TUI. The task stages the
// send via `Net::SendReady` *then* emits the offer, so a peer's `/accept`
// can never race ahead of `active_send`.
app.sys(format!("hashing {path}…"));
tokio::task::spawn_blocking(move || match ft::prepare_send(&path) {
Ok(s) => {
let _ = atx.send(Net::SendReady {
id: id.clone(),
src: s.src,
temp: s.temp,
name: s.name.clone(),
size: s.size,
to: to.clone(),
});
let mut frame = json!({
"_ft":"offer","id": id,"name": s.name,"size": s.size,"sha256": s.sha256,"dir": s.dir
});
if let Some(t) = &to {
frame["to"] = json!(t);
}
let _ = out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())));
}
Err(e) => {
let _ = atx.send(Net::Err(format!("send failed: {e}")));
}
});
}
fn broadcast_acl(out: &UnboundedSender<WsMsg>, room: &fernet::Fernet, app: &App) {
let drivers: Vec<&String> = app.drivers.iter().collect();
let sudoers: Vec<&String> = app.sudoers.iter().collect();
send_frame(
out,
room,
json!({
"_perm":"acl","owner": app.owner, "drivers": drivers, "sudoers": sudoers
}),
);
}
/// Stream a payload from disk to the clergy as `_ft` chunks (background, paced).
/// Reads `src` in `CHUNK` blocks so only one chunk is ever resident — a multi-GB
/// appliance streams with flat memory. Deletes `src` afterward if it's a temp tar.
fn spawn_send(
id: String,
src: std::path::PathBuf,
temp: bool,
out: UnboundedSender<WsMsg>,
room: Arc<fernet::Fernet>,
) {
tokio::task::spawn_blocking(move || {
use std::io::Read;
let mut f = match std::fs::File::open(&src) {
Ok(f) => f,
Err(_) => return,
};
let mut buf = vec![0u8; ft::CHUNK];
let mut seq = 0usize;
loop {
let n = match f.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
let frame =
json!({"_ft":"chunk","id": id,"seq": seq,"data": STANDARD.encode(&buf[..n])});
if out
.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())))
.is_err()
{
break;
}
seq += 1;
std::thread::sleep(Duration::from_millis(2));
}
let _ = out.send(WsMsg::Text(
room.encrypt(json!({"_ft":"done","id": id}).to_string().as_bytes()),
));
if temp {
let _ = std::fs::remove_file(&src);
}
});
}
fn handle_ft(
f: ft::Ft,
app: &mut App,
active: &mut Option<ActiveSend>,
out: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
downloads: &std::path::Path,
) -> Option<std::path::PathBuf> {
// Set to the saved path when a transfer completes & verifies, so the caller
// can auto-bridge it into a running sandbox.
let mut saved = None;
match f {
ft::Ft::Offer(o) => {
if o.from == app.me {
return None; // our own offer echo
}
// Direct send addressed to a specific member: everyone receives the
// broadcast, but only the named recipient is prompted.
if let Some(to) = &o.to {
if to != &app.me {
return None;
}
}
app.sys(format!(
"† {} offers {} ({}{}){} — /accept or /reject",
o.from,
o.name,
ft::human(o.size as usize),
if o.dir { ", directory" } else { "" },
if o.to.is_some() { " directly to you" } else { "" },
));
app.transfers.insert(
o.id.clone(),
Transfer {
meta: o.clone(),
sink: None,
accepted: false,
},
);
app.pending_offer = Some(o);
}
ft::Ft::Accept(id) => {
if let Some(a) = active.as_mut() {
if a.id == id && !a.sending {
a.sending = true;
spawn_send(id, a.src.clone(), a.temp, out.clone(), room.clone());
app.sys("transfer accepted — sending…");
}
}
}
ft::Ft::Reject(id) => {
if active.as_ref().map(|a| a.id == id).unwrap_or(false) {
app.sys("transfer rejected");
if let Some(a) = active.take() {
if a.temp {
let _ = std::fs::remove_file(&a.src); // discard the temp tar
}
}
}
}
ft::Ft::Chunk { id, data } => {
// Stream straight to the disk-backed sink (created lazily on the
// first chunk). Enforce the declared size (clamped to STREAM_MAX) on
// receipt — a sender can lie about `size` or just keep streaming, so
// never let an accepted transfer grow past the cap.
let mut overflow = false;
let mut io_err: Option<String> = None;
if let Some(t) = app.transfers.get_mut(&id) {
if t.accepted {
if t.sink.is_none() {
match ft::Sink::create(downloads, &id) {
Ok(s) => t.sink = Some(s),
Err(e) => io_err = Some(e.to_string()),
}
}
if let Some(s) = t.sink.as_mut() {
let cap = (t.meta.size).min(ft::STREAM_MAX);
if s.written() + data.len() as u64 > cap {
overflow = true;
} else if let Err(e) = s.write(&data) {
io_err = Some(e.to_string());
}
}
}
}
if overflow {
if let Some(t) = app.transfers.remove(&id) {
if let Some(s) = t.sink {
s.abort();
}
app.err(format!(
"{} — transfer exceeds declared size (max {}), aborted",
t.meta.name,
ft::human((t.meta.size).min(ft::STREAM_MAX) as usize)
));
}
} else if let Some(e) = io_err {
if let Some(t) = app.transfers.remove(&id) {
if let Some(s) = t.sink {
s.abort();
}
app.err(format!("{} — write failed: {e}", t.meta.name));
}
}
}
ft::Ft::Done(id) => {
if let Some(t) = app.transfers.remove(&id) {
if t.accepted {
match t.sink {
Some(s) => match s.finish() {
Ok((tmp, sha)) => {
if sha != t.meta.sha256 {
let _ = std::fs::remove_file(&tmp);
app.err(format!(
"{} — SHA-256 mismatch, discarded",
t.meta.name
));
} else {
match ft::commit(downloads, &t.meta, &tmp) {
Ok(p) => {
app.sys(format!(
"† saved {} ({}) — verified ✓",
p.display(),
ft::human(t.meta.size as usize)
));
saved = Some(p);
}
Err(e) => app.err(format!("save failed: {e}")),
}
}
}
Err(e) => {
app.err(format!("{} — finalize failed: {e}", t.meta.name))
}
},
None => app.err(format!("{} — no data received", t.meta.name)),
}
}
}
if app
.pending_offer
.as_ref()
.map(|o| o.id == id)
.unwrap_or(false)
{
app.pending_offer = None;
}
}
}
saved
}
/// Put the terminal back the way we found it: leave raw mode, leave the
/// alternate screen, stop mouse capture, show the cursor. Best-effort — every
/// step is independent so one failing (e.g. already-restored) can't strand the
/// rest. Safe to call more than once.
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
let _ = execute!(std::io::stdout(), crossterm::cursor::Show);
}
/// RAII: restores the terminal on drop. As long as a guard is alive, *any* exit
/// from `run` — normal return, `?` error, or a panic unwinding through the
/// frame — leaves the user's terminal (or tmux pane) usable instead of stuck in
/// raw/alt-screen mode.
struct TermGuard;
impl Drop for TermGuard {
fn drop(&mut self) {
restore_terminal();
}
}
pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme) -> Result<()> {
let (tx, mut rx) = unbounded_channel::<Net>();
let app_tx = tx.clone();
let write = net::open(&session, tx.clone()).await?;
// Carries the result of a background reconnect handshake back to the loop.
let (recon_tx, mut recon_rx) = unbounded_channel::<std::result::Result<Session, String>>();
// All outgoing frames funnel through here so background tasks (file chunks,
// PTY relay) can transmit without owning the socket.
let (out_tx, out_rx) = unbounded_channel::<WsMsg>();
// The websocket writer runs on its own task so a slow / backpressured socket
// (notably while relaying a sandbox PTY stream to a remote peer) can never
// stall the UI loop's keyboard + chat handling. On reconnect the loop hands
// the writer a fresh sink through `sink_tx`.
let (sink_tx, sink_rx) = unbounded_channel::<net::WsSink>();
tokio::spawn(writer_task(write, out_rx, sink_rx));
let (pty_tx, mut pty_rx): (UnboundedSender<Vec<u8>>, UnboundedReceiver<Vec<u8>>) =
unbounded_channel();
let (broker_tx, mut broker_rx) = unbounded_channel::<BrokerMsg>();
let mut broker: Option<sbx::Sandbox> = None;
let mut broker_meta: Option<(sbx::Backend, String)> = None;
let mut launching = false;
let mut announced_dims: Option<(u16, u16)> = None;
let mut active_send: Option<ActiveSend> = None;
let mut send_seq: u64 = 0;
// The local AI agent subprocess this client spawned via `/ai start`, if any.
let mut agent: Option<std::process::Child> = None;
let downloads = PathBuf::from("./downloads");
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let mut term = Terminal::new(CrosstermBackend::new(stdout))?;
// From here on the terminal is in raw/alt-screen mode. The guard restores it
// on every exit path (return, `?`, panic); the panic hook additionally prints
// the panic *after* restoring, so a crash leaves a readable message in the
// pane instead of garbled raw-mode output.
let _term_guard = TermGuard;
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
default_panic(info);
}));
// Graceful shutdown on signals: a `kill`, a closed tmux pane (SIGHUP), or a
// Ctrl-C delivered while NOT in raw mode all break the loop cleanly so the
// guard above can restore the terminal — no more being "booted" with a
// broken pane. (In raw mode Ctrl-C arrives as a key event, handled below.)
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())?;
let mut app = App::new(session.username.clone());
app.password = params.password.clone();
// Seed the roster width from the active vestment so a `--theme` with a wider
// roster is honoured; from here on the live layout owns it (so /theme swaps
// don't stomp a width the user has set with /layout).
app.layout.set_roster_width(theme.roster_width);
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(50));
let result = loop {
// Apply the sandbox scrollback offset (0 = follow live).
//
// vt100 0.15.2 panics ("subtract with overflow" in grid::visible_rows)
// if the scrollback offset ever exceeds the visible grid height: it does
// `rows_len - offset` on usize without clamping. Cap our offset to the
// grid height so a fast scroll can never cross that line and crash us.
if let Some(v) = &mut app.sandbox {
let rows = v.parser.screen().size().0 as usize;
app.sbx_scroll = app.sbx_scroll.min(rows);
v.parser.set_scrollback(app.sbx_scroll);
}
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
break Err(e.into());
}
if broker.is_some() {
if let Ok(sz) = term.size() {
let dims = sbx_grid(sz.width, sz.height, &app.layout);
if announced_dims != Some(dims) {
announced_dims = Some(dims);
if let Some(sb) = &broker {
let _ = sb.resize(dims.0, dims.1);
}
send_frame(
&out_tx,
&session.room,
json!({"_sbx":"resize","rows":dims.0,"cols":dims.1}),
);
}
}
}
tokio::select! {
biased; // keyboard first, so Ctrl-C / Esc are never starved by output floods
maybe = events.next() => {
match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
app.error = None; // any keypress dismisses the error popup
// Ctrl-Q always quits. Ctrl-C quits too — *unless* we're
// driving the sandbox, where it must reach the PTY as an
// interrupt (handled in the `app.driving` branch below).
if k.modifiers.contains(KeyModifiers::CONTROL)
&& (matches!(k.code, KeyCode::Char('q'))
|| (matches!(k.code, KeyCode::Char('c')) && !app.driving))
{
break Ok(());
}
// Masked sudo-password prompt (Option C). When open it
// swallows EVERY keystroke so the password can never leak
// into chat, the PTY, or an outbound frame — it lives only
// in `app.sudo_prompt.password` until it's moved into the
// launch task and fed to `sudo -S` over stdin.
if app.sudo_prompt.is_some() {
match k.code {
KeyCode::Enter => {
// SAFETY of secrecy: `take()` moves the buffer
// out; it's never cloned into any logged/visible
// surface. The password is handed to spawn_launch
// and dropped there after stdin is fed.
let SudoPrompt { password, pending } =
app.sudo_prompt.take().unwrap();
if password.is_empty() {
app.sys("† cancelled — empty password");
} else {
match pending {
PendingPrivileged::Launch(pending) => {
launching = true;
app.sys("🔒 authenticating + launching…");
spawn_launch(
pending.backend,
pending.image,
app.me.clone(),
pending.members,
pending.rows,
pending.cols,
pending.start_daemon,
pending.install_first,
Some(password),
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
);
}
PendingPrivileged::VboxInstall(p) => {
// Root authenticated → install VirtualBox
// (sudo -S reads this password) then boot
// the VM. The password is dropped inside
// spawn_vm_execute after stdin is fed.
app.sys("🔒 authenticating + installing VirtualBox…");
let room = p.room.clone();
spawn_vm_execute(
p.vm,
p.conflicts,
p.needs_install,
p.needs_import,
&mut app,
&app_tx,
&out_tx,
&room,
Some(password),
);
}
}
}
}
KeyCode::Esc => {
app.sudo_prompt = None;
app.sys("† sudo cancelled — launch aborted");
}
KeyCode::Backspace => {
if let Some(p) = app.sudo_prompt.as_mut() {
p.password.pop();
}
}
KeyCode::Char(c) => {
if let Some(p) = app.sudo_prompt.as_mut() {
p.password.push(c);
}
}
_ => {}
}
} else if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('x'))
&& !app.driving
{
// Panic kill switch (sandbox owner): revoke every
// non-owner driver, interrupt whatever is running in
// the PTY, and re-broadcast the locked-down ACL. Cuts a
// runaway agent (or human) off mid-command. Gated on
// `!app.driving` so that while YOU hold the shell, Ctrl-X
// reaches the PTY instead (nano's quit; key_to_pty sends
// 0x18) — release with F2 first to arm the kill switch.
if let Some(sb) = &mut broker {
let owner = app.me.clone();
app.drivers.retain(|u| *u == owner);
app.sudoers.retain(|u| *u == owner);
app.agent_sbx_allow = false;
let _ = sb.write_input(&[0x03]); // Ctrl-C into the shell
broadcast_acl(&out_tx, &session.room, &app);
app.sys("† kill switch — revoked all drive + interrupted the shell");
} else {
app.sys("kill switch is for the sandbox owner (you don't hold the PTY)");
}
} else if k.modifiers.contains(KeyModifiers::CONTROL)
&& k.modifiers.contains(KeyModifiers::ALT)
&& matches!(k.code, KeyCode::Char('p'))
{
// Conjure a brand-new procedural vestment (not a bundled
// preset): fresh palette + sigil, rolled from the clock.
theme = Theme::random();
app.sys(format!(
"{} conjured vestment '{}'",
theme.sigil, theme.name
));
} else if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('r'))
&& !app.connected
{
// Reconnect: re-run the SRP handshake off-thread so the UI
// stays responsive, then re-attach the websocket on success.
if !app.reconnecting {
app.reconnecting = true;
app.sys("† reconnecting…");
let p = params.clone();
let rtx = recon_tx.clone();
tokio::task::spawn_blocking(move || {
let r = net::authenticate(
&p.ip, p.port, &p.user, &p.password, p.no_tls, p.insecure,
)
.map_err(|e| e.to_string());
let _ = rtx.send(r);
});
}
} else if app.vbox_picker.is_some() {
// Modal VM picker (from a bare `/sbx vbox`): arrow
// keys move the highlight, Enter/Tab boots the selected VM
// on this machine (host-frictionless path; a non-host who
// needs install/import is steered to re-issue with `yes`),
// Esc dismisses. Intercepts keys so the list stays put.
match k.code {
KeyCode::Up => {
if let Some(p) = &mut app.vbox_picker {
p.selected = p.selected.saturating_sub(1);
}
}
KeyCode::Down => {
if let Some(p) = &mut app.vbox_picker {
p.selected =
(p.selected + 1).min(p.vms.len().saturating_sub(1));
}
}
KeyCode::Enter | KeyCode::Tab => {
let vm = app
.vbox_picker
.take()
.and_then(|p| p.vms.into_iter().nth(p.selected));
if let Some(vm) = vm {
launch_vbox_gui(
&mut app, vm, false, &app_tx, &out_tx, &session.room,
);
}
}
KeyCode::Esc => {
app.vbox_picker = None;
app.sys("† picker dismissed");
}
_ => {} // ignore other keys so the picker stays put
}
} else if app.show_help {
// tmux-style nav: up/down highlight a cluster, left/right
// (or Enter) collapse/expand it, PgUp/PgDn scroll the
// overflow, only Esc closes so stray keys can't dismiss a
// menu you're still reading.
let count = ui::help_cluster_count(&theme);
let max = term
.size()
.map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme))
.unwrap_or(0);
// keep the expand state sized to the clusters
if app.help_expanded.len() != count {
app.help_expanded.resize(count, false);
}
match k.code {
KeyCode::Up => {
app.help_selected = app.help_selected.saturating_sub(1);
}
KeyCode::Down => {
app.help_selected =
(app.help_selected + 1).min(count.saturating_sub(1));
}
KeyCode::Left => {
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
*e = false; // collapse the highlighted cluster
}
}
KeyCode::Right => {
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
*e = true; // reveal the highlighted cluster
}
}
KeyCode::Enter | KeyCode::Char(' ') => {
if let Some(e) = app.help_expanded.get_mut(app.help_selected) {
*e = !*e; // toggle
}
}
KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10),
KeyCode::PageDown => app.help_scroll = (app.help_scroll + 10).min(max),
KeyCode::Home => app.help_scroll = 0,
KeyCode::End => app.help_scroll = max,
KeyCode::Esc | KeyCode::F(1) => {
// Esc dismisses; F1 toggles the overlay back shut
app.show_help = false;
app.help_scroll = 0;
}
_ => {} // ignore other keys so the menu stays put
}
// clamp scroll in case collapsing shrank the content
let max = term
.size()
.map(|s| ui::help_max_scroll(s.width, s.height, &app, &theme))
.unwrap_or(0);
app.help_scroll = app.help_scroll.min(max);
} else if k.code == KeyCode::F(1) {
app.open_help(); // F1 from any mode
} else if k.code == KeyCode::F(2) {
if app.sandbox.is_none() {
} else if app.can_drive() {
app.driving = !app.driving;
} else {
app.sys("you don't have drive permission — the owner can /grant you");
}
} else if k.code == KeyCode::F(4) {
// Fullscreen the terminal (cycle terminal → chat → split).
// Caught before the drive branch so it works mid-session;
// F-keys aren't forwarded to the shell anyway (key_to_pty).
if app.sandbox.is_some() {
app.layout.cycle_zoom();
announced_dims = None; // re-sync PTY to the new height
app.sys(format!("† {}", app.layout.describe()));
} else {
app.sys("no sandbox to fullscreen — /sbx <type> first");
}
} else if k.code == KeyCode::F(5) {
// Enter/cycle interactive layout editing: pick a pane to
// resize (Terminal → Chat → Roster → off). Clicking a pane
// selects it directly; arrows then resize it.
app.cycle_focus();
match app.focused_pane {
Some(p) => app.sys(format!(
"† editing {} — arrows resize · Esc done",
pane_label(p)
)),
None => app.sys("† layout editing off"),
}
} else if app.focused_pane.is_some() && !app.driving {
// Interactive layout editing: a pane is selected (via click
// or F5). Arrows resize it live; Esc/Enter finishes. Sits
// before the driving branch but is gated on !driving so the
// shell still gets its keys while you hold the PTY.
let pane = app.focused_pane.unwrap();
let has_term = app.sandbox.is_some();
let dir = match k.code {
KeyCode::Up => Some(Dir::Up),
KeyCode::Down => Some(Dir::Down),
KeyCode::Left => Some(Dir::Left),
KeyCode::Right => Some(Dir::Right),
_ => None,
};
match k.code {
KeyCode::Esc | KeyCode::Enter => {
app.focused_pane = None;
app.sys(format!("† {}", app.layout.describe()));
}
_ if dir.is_some() => {
// Every pane is resizable on both axes wherever a
// divider bounds it; resize_focused moves the right
// one (or reports NoAxisHere so we can hint).
match app.layout.resize_focused(pane, dir.unwrap(), has_term) {
Resize::Moved => {
// Any divider move can change the terminal's
// rect (height *or* width now), so force a
// PTY re-sync to rebroadcast the new grid.
announced_dims = None;
app.sys(format!("† {}", app.layout.describe()));
}
Resize::NoAxisHere => {
app.sys(
"no divider on that axis here — F5 to the input bar to grow the compose box, or launch a sandbox for terminal height",
);
}
}
}
// Typing a printable char is an escape hatch: drop out
// of layout-edit mode and feed the key to the compose box.
// Without this, a stray click into edit mode silently
// swallows every keystroke and feels like a freeze.
KeyCode::Char(c)
if !k.modifiers.contains(KeyModifiers::CONTROL)
&& !k.modifiers.contains(KeyModifiers::ALT) =>
{
app.focused_pane = None;
app.input.push(c);
}
_ => {} // ignore other keys while editing
}
} else if app.driving {
// Esc is NOT a release key here — vim & friends need it,
// so it's forwarded to the PTY like any other key (see
// key_to_pty). Press F2 (handled above) to release the
// shell back to chat.
if k.code == KeyCode::PageUp {
// Scroll the shared shell's scrollback without releasing the
// drive: PgUp/PgDn aren't forwarded to the PTY anyway.
app.sbx_scroll = (app.sbx_scroll + sbx_page(&app)).min(2000);
} else if k.code == KeyCode::PageDown {
app.sbx_scroll = app.sbx_scroll.saturating_sub(sbx_page(&app));
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
if let Some(sb) = &mut broker {
// I own the sandbox: write straight to the PTY — instant,
// and Ctrl-C can't be queued behind outgoing output.
let _ = sb.write_input(&bytes);
} else {
send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)}));
}
}
} else {
match k.code {
KeyCode::Enter => {
let line = app.input.trim().to_string();
app.input.clear();
app.chat_scroll = 0; // jump back to live on send
handle_command(&line, &mut app, &mut theme, &mut send_seq,
&mut broker, &mut broker_meta, &mut launching, &mut announced_dims,
&out_tx, &pty_tx, &broker_tx, &app_tx, &session, &term,
&mut agent, &params);
}
KeyCode::Backspace => { app.input.pop(); }
// Scroll: ↑/↓ scroll the sandbox terminal if one is up,
// otherwise the chat. PgUp/PgDn always scroll chat.
KeyCode::Up => {
if app.sandbox.is_some() {
app.sbx_scroll = (app.sbx_scroll + 1).min(2000);
} else {
app.chat_scroll = (app.chat_scroll + 1).min(app.lines.len().saturating_sub(1));
}
}
KeyCode::Down => {
if app.sandbox.is_some() {
app.sbx_scroll = app.sbx_scroll.saturating_sub(1);
} else {
app.chat_scroll = app.chat_scroll.saturating_sub(1);
}
}
KeyCode::PageUp => {
app.chat_scroll = (app.chat_scroll + 10).min(app.lines.len().saturating_sub(1));
}
KeyCode::PageDown => {
app.chat_scroll = app.chat_scroll.saturating_sub(10);
}
KeyCode::Home => {
app.chat_scroll = app.lines.len().saturating_sub(1);
}
KeyCode::End => {
app.chat_scroll = 0;
app.sbx_scroll = 0;
}
KeyCode::Char(c) => app.input.push(c),
_ => {}
}
}
}
Some(Ok(Event::Mouse(m))) => {
// Mouse wheel scrolls the sandbox terminal if one is up
// (incl. while driving), otherwise the chat — mirrors ↑/↓.
match m.kind {
MouseEventKind::ScrollUp => {
if app.sandbox.is_some() {
app.sbx_scroll = (app.sbx_scroll + 3).min(2000);
} else {
app.chat_scroll = (app.chat_scroll + 3).min(app.lines.len().saturating_sub(1));
}
}
MouseEventKind::ScrollDown => {
if app.sandbox.is_some() {
app.sbx_scroll = app.sbx_scroll.saturating_sub(3);
} else {
app.chat_scroll = app.chat_scroll.saturating_sub(3);
}
}
// Left-click a pane to select it for interactive resize
// (then arrows adjust it; Esc finishes). Skipped while
// driving the shell or with an overlay up, so clicks there
// don't hijack the terminal/help/picker.
MouseEventKind::Down(MouseButton::Left)
if !app.driving && !app.show_help && app.vbox_picker.is_none() =>
{
if let Ok(sz) = term.size() {
match ui::pane_at(sz.width, sz.height, &app, m.column, m.row) {
// Clicking the compose box must NOT enter layout-edit
// mode — that's where you type, and locking it on a
// click is exactly the "stuck, can't type" footgun.
Some(p) if p != Pane::Input => {
app.focused_pane = Some(p);
app.sys(format!(
"† editing {} — arrows resize · Esc done",
pane_label(p)
));
}
_ => {
app.focused_pane = None;
}
}
}
}
_ => {}
}
}
Some(Err(e)) => break Err(e.into()),
_ => {}
}
}
net = rx.recv() => {
// Drain a burst of incoming frames per turn. The reader funnels both
// chat and high-volume `_sbx:data` terminal output through this one
// channel, and the loop redraws once per turn — so handling a single
// frame per redraw lets a busy sandbox stream bury chat arbitrarily far
// back in the queue. Pulling up to a cap of ready frames now keeps chat
// latency bounded no matter how hard the shared shell is scrolling.
let Some(first) = net else { break Ok(()) };
let mut burst = vec![first];
drain_ready(&mut rx, &mut burst, 256);
for ev in burst {
match ev {
Net::SbxInput { from, bytes } => {
if let Some(sb) = &mut broker {
if app.drivers.contains(&from) {
let _ = sb.write_input(&bytes);
}
}
}
Net::Ft(f) => {
// On a received, SHA-verified file, auto-bridge it into
// a sandbox we host so the whole clergy can use it from
// the shared shell. Runs off the UI thread (docker/mp
// exec + tar stream) and reports back via the app channel.
// A local backend already shares the host fs — nothing to do.
if let Some(path) =
handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads)
{
// A received VirtualBox appliance auto-imports so the VM
// registers locally and the recipient can immediately
// `/sbx vbox gui <vm>`. Off-thread (VBoxManage import
// is slow); reports back via the app channel.
let ext = path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase());
if matches!(ext.as_deref(), Some("ova") | Some("ovf")) {
let tx = app_tx.clone();
let ova = path.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::import_appliance(&ova)
})
.await;
let _ = match res {
Ok(Ok(vm)) => tx.send(Net::Sys(format!(
"† imported VM {vm} — `/sbx vbox gui {vm}` to boot it"
))),
Ok(Err(e)) => tx.send(Net::Sys(format!(
"(received .ova not auto-imported: {e})"
))),
Err(e) => tx.send(Net::Err(format!("import task: {e}"))),
};
});
}
if let Some((be, name)) = &broker_meta {
if !matches!(be, sbx::Backend::Local) {
let (be, name) = (*be, name.clone());
let run_user = sbx::run_user_for(
be,
app.owner.as_deref().unwrap_or(app.me.as_str()),
);
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::push(be, &name, &run_user, &path)
})
.await;
let _ = match res {
Ok(Ok(dest)) => tx.send(Net::Sys(format!(
"† bridged into sandbox → {dest}"
))),
Ok(Err(e)) => tx.send(Net::Sys(format!(
"(received file not bridged into sandbox: {e})"
))),
Err(e) => tx.send(Net::Err(format!("bridge task: {e}"))),
};
});
}
}
}
}
// The broker renders its sandbox locally from the PTY, so it
// ignores its own echoed status/data; everyone else uses them.
Net::SbxData(b) => {
if broker.is_none() {
if let Some(v) = &mut app.sandbox { v.parser.process(&b); }
}
}
Net::SbxStatus { .. } if broker.is_some() => {}
ev @ Net::Joined(_) => {
// Someone (re)entered and missed everything broadcast
// before they connected. If we host the sandbox, replay
// it so it reappears for them: `status:ready` makes the
// pane show up, the screen snapshot (`_sbx:data`) paints
// its current contents instead of a blank pane, and the
// ACL re-broadcast re-syncs grant state (also what makes
// `/ai start <m> allow` reach a freshly-joined agent).
// The status/data handlers are idempotent, so members
// already in the room aren't disturbed by the replay.
if broker.is_some() {
if let (Some(v), Some((be, sbx_name))) = (&app.sandbox, &broker_meta) {
let (rows, cols) = v.parser.screen().size();
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready",
"backend": be.label(),"engine": be.engine(),"name": sbx_name,
"rows": rows,"cols": cols
}));
let snap = v.parser.screen().contents_formatted();
send_frame(&out_tx, &session.room, json!({
"_sbx":"data","b64": STANDARD.encode(&snap)
}));
}
broadcast_acl(&out_tx, &session.room, &app);
}
app.apply(ev);
}
Net::SendReady { id, src, temp, name, size, to } => {
// The off-thread hash finished and emitted the offer; record
// the staged send so a peer's /accept can start streaming.
active_send = Some(ActiveSend { id, src, temp, sending: false });
match to {
Some(t) => app.sys(format!(
"offered {} ({}) to {} — waiting for an /accept",
name,
ft::human(size as usize),
t
)),
None => app.sys(format!(
"offered {} ({}) to the room — waiting for an /accept",
name,
ft::human(size as usize)
)),
}
}
other => app.apply(other),
}
}
}
msg = broker_rx.recv() => {
match msg {
Some(BrokerMsg::Ready { sb, backend, name, rows, cols }) => {
broker = Some(sb);
broker_meta = Some((backend, name.clone()));
announced_dims = Some((rows, cols));
launching = false;
// Local sandbox view — broker renders straight from the PTY.
app.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 2000),
backend: backend.label().to_string(),
});
app.sys(format!("† sandbox summoned ({}) — /drive to take the shell", backend.label()));
app.owner = Some(app.me.clone());
app.drivers.clear();
app.drivers.insert(app.me.clone());
app.sudoers.clear();
app.sudoers.insert(app.me.clone()); // owner = superuser
if app.agent_sbx_allow {
// Re-apply a `/ai start … allow` grant the launch reset.
if let Some(n) = &app.agent_name {
app.drivers.insert(n.clone());
}
}
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready","backend": backend.label(),
"engine": backend.engine(),"name": name,"rows": rows,"cols": cols
}));
broadcast_acl(&out_tx, &session.room, &app);
}
Some(BrokerMsg::Failed) => { launching = false; }
None => {}
}
}
recon = recon_rx.recv() => {
if let Some(result) = recon {
app.reconnecting = false;
match result {
Ok(s) => {
session = s;
match net::open(&session, tx.clone()).await {
Ok(w) => {
let _ = sink_tx.send(w);
app.sys("† websocket re-attached — syncing…");
// If we host the sandbox, re-announce it so the
// rest of the house re-syncs the shared shell.
if let Some((be, sbx_name)) = &broker_meta {
if let Some(v) = &app.sandbox {
let (rows, cols) = v.parser.screen().size();
send_frame(&out_tx, &session.room, json!({
"_sbx":"status","state":"ready",
"backend": be.label(),"engine": be.engine(),"name": sbx_name,
"rows": rows,"cols": cols
}));
broadcast_acl(&out_tx, &session.room, &app);
}
}
}
Err(e) => app.err(format!("reconnect failed: {e}")),
}
}
Err(e) => app.sys(format!("reconnect failed: {e}")),
}
}
}
pty = pty_rx.recv() => {
if let Some(mut bytes) = pty {
// Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips,
// no flood. Render locally now so the owner sees output instantly.
while let Ok(more) = pty_rx.try_recv() {
bytes.extend_from_slice(&more);
if bytes.len() > 256 * 1024 { break; }
}
if let Some(v) = &mut app.sandbox { v.parser.process(&bytes); }
send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)}));
}
}
_ = sigterm.recv() => { break Ok(()); }
_ = sighup.recv() => { break Ok(()); }
_ = tick.tick() => { app.spin = app.spin.wrapping_add(1); }
}
};
if let Some(mut sb) = broker.take() {
sb.stop();
if let Some((be, name)) = broker_meta.take() {
sbx::teardown(be, &name);
}
}
if let Some(mut child) = agent.take() {
let _ = child.kill();
let _ = child.wait();
}
disable_raw_mode()?;
execute!(
term.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
term.show_cursor()?;
result
}
enum BrokerMsg {
Ready {
sb: sbx::Sandbox,
backend: sbx::Backend,
name: String,
rows: u16,
cols: u16,
},
Failed,
}
/// Owns the websocket write half and drains all outgoing frames off the UI
/// loop. Because `sink.send().await` can block on a backpressured socket (e.g.
/// while the server relays a sandbox PTY stream to a slow remote peer), keeping
/// it here means that stall never starves keyboard input or chat rendering. A
/// reconnect delivers a fresh sink via `sink_rx`; the reader task independently
/// surfaces `Net::Closed`, so a dead sink here just drops frames until then.
async fn writer_task(
mut sink: net::WsSink,
mut out_rx: UnboundedReceiver<WsMsg>,
mut sink_rx: UnboundedReceiver<net::WsSink>,
) {
loop {
tokio::select! {
biased;
// Swap in a reconnect's fresh sink before draining more frames.
new_sink = sink_rx.recv() => {
if let Some(s) = new_sink {
sink = s;
}
}
msg = out_rx.recv() => {
let Some(first) = msg else { return }; // app exiting
// Coalesce a burst (file chunks / PTY relay) into one batch.
let mut batch = vec![first];
while let Ok(m) = out_rx.try_recv() {
batch.push(m);
if batch.len() >= 64 { break; }
}
for m in batch {
if sink.send(m).await.is_err() {
break; // sink dead — wait for a reconnect to replace it
}
}
}
}
}
}
/// Pull up to `cap` *already-ready* items out of `rx` (without awaiting) in FIFO
/// order, appending to `buf`. The UI loop uses this to drain a burst of incoming
/// frames per turn so a high-volume `_sbx:data` stream can't bury chat behind a
/// one-frame-per-redraw cap.
fn drain_ready<T>(rx: &mut UnboundedReceiver<T>, buf: &mut Vec<T>, cap: usize) {
while buf.len() < cap {
match rx.try_recv() {
Ok(m) => buf.push(m),
Err(_) => break, // empty or disconnected — nothing more to take right now
}
}
}
#[allow(clippy::too_many_arguments)]
fn handle_command(
line: &str,
app: &mut App,
theme: &mut Theme,
send_seq: &mut u64,
broker: &mut Option<sbx::Sandbox>,
broker_meta: &mut Option<(sbx::Backend, String)>,
launching: &mut bool,
announced_dims: &mut Option<(u16, u16)>,
out_tx: &UnboundedSender<WsMsg>,
pty_tx: &UnboundedSender<Vec<u8>>,
broker_tx: &UnboundedSender<BrokerMsg>,
app_tx: &UnboundedSender<Net>,
session: &Session,
term: &Terminal<CrosstermBackend<std::io::Stdout>>,
agent: &mut Option<std::process::Child>,
params: &net::ConnParams,
) {
let room = &session.room;
if line == "/help" || line == "/?" {
app.open_help();
} else if line == "/clear" || line == "/cls" {
// Local-only: wipe this client's chat scrollback. Doesn't touch the
// room — other peers keep their own history.
app.lines.clear();
app.chat_scroll = 0;
app.sys("† chat cleared");
} else if line == "/pw" || line == "/password" {
// Show the room password locally (never broadcast). Handy when the
// server's password was autogenerated and you need to read it off / share
// it out-of-band to invite someone into the room.
if app.password.is_empty() {
app.sys("† no room password (joined without one)");
} else {
app.sys(format!("† room password: {}", app.password));
}
} else if let Some(rest) = line.strip_prefix("/theme") {
// Live vestment switch: `/theme <name>`, or bare `/theme` to list options.
let name = rest.trim();
if name.is_empty() {
app.sys(format!(
"vestments: {} · random · save [name] — /theme <name>",
Theme::available().join(" · ")
));
} else if name == "random" {
// Same as Ctrl+Alt+P — roll a fresh procedural vestment.
*theme = Theme::random();
app.sys(format!(
"{} conjured vestment '{}'",
theme.sigil, theme.name
));
} else if name == "save" || name.starts_with("save ") {
// Persist the vestment you're currently wearing (e.g. a `random`
// roll you like) to themes/<slug>.toml so it sticks around. Bare
// `/theme save` reuses the theme's own generated name.
let want = name[4..].trim();
let want = if want.is_empty() {
theme.name.clone()
} else {
want.to_string()
};
match theme.save(&want) {
Ok(slug) => app.sys(format!(
"{} saved vestment '{slug}' — re-don it anytime with /theme {slug}",
theme.sigil
)),
Err(e) => app.err(format!("couldn't save vestment: {e}")),
}
} else {
match Theme::by_name(name) {
Ok(t) => {
*theme = t;
app.sys(format!("donned the {name} vestments"));
}
Err(_) => app.err(format!(
"no theme '{name}' — try: {}",
Theme::available().join(" · ")
)),
}
}
} else if let Some(rest) = line.strip_prefix("/layout") {
// Named layout presets only — live resizing is interactive now (F4
// fullscreen, F5 or click a pane then arrows). `/layout` keeps just the
// memorable verbs for saving/recalling arrangements, plus `reset`.
// Loading a preset clears announced_dims so the run loop re-syncs (and
// broadcasts) the live PTY size on the next tick.
let rest = rest.trim();
let (cmd, arg) = rest
.split_once(char::is_whitespace)
.map(|(c, a)| (c, a.trim()))
.unwrap_or((rest, ""));
let mut resized = false;
match cmd {
"" => {
app.sys(format!("† layout: {}", app.layout.describe()));
app.sys(" resize live: F4 fullscreen · F5 or click a pane, then arrows · Esc done");
app.sys(format!(
" presets: {} — /layout save <name> · load <name> · rm <name>",
once_or_none(Layout::available())
));
}
"reset" | "default" => {
let roster = app.layout.roster_width();
app.layout = Layout::default();
app.layout.set_roster_width(roster); // keep your roster choice on reset
resized = true;
app.sys(format!("† layout reset — {}", app.layout.describe()));
}
"save" if !arg.is_empty() => match app.layout.save(arg) {
Ok(slug) => app.sys(format!(
"† saved layout '{slug}' — re-apply anytime with /layout load {slug}"
)),
Err(e) => app.err(format!("couldn't save layout: {e}")),
},
"load" | "apply" if !arg.is_empty() => match Layout::by_name(arg) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("† loaded layout '{arg}' — {}", app.layout.describe()));
}
Err(_) => app.err(format!(
"no saved layout '{arg}' — saved: {}",
once_or_none(Layout::available())
)),
},
"list" | "presets" | "ls" => {
app.sys(format!("† saved layouts: {}", once_or_none(Layout::available())));
}
"rm" | "delete" | "del" if !arg.is_empty() => match Layout::remove(arg) {
Ok(()) => app.sys(format!("† deleted layout '{arg}'")),
Err(e) => app.err(format!("{e}")),
},
// Bare `/layout <name>` → load a saved preset if it exists.
name => match Layout::by_name(name) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("† loaded layout '{name}' — {}", app.layout.describe()));
}
Err(_) => app.sys(
"usage: /layout [save <name>|load <name>|list|rm <name>|reset] — resize live with F4/F5 or click",
),
},
}
if resized {
*announced_dims = None; // force the run loop to re-sync the PTY size
}
} else if line == "/drive" {
// Mobile-friendly alternative to F2 (no function key needed).
if app.sandbox.is_none() {
app.sys("no sandbox running — /sbx <type> first");
} else if app.can_drive() {
app.driving = true;
app.sys("† drive mode ON — type into the shell (Esc reaches vim etc.) · press F2 to release");
} else {
app.sys("you don't have drive permission — the owner can /grant you");
}
} else if let Some(rest) = line.strip_prefix("/sendroom ") {
// Offer a file/dir to the whole room — anyone may /accept.
offer_payload(app, send_seq, out_tx, room, app_tx, rest.trim(), None);
} else if let Some(rest) = line.strip_prefix("/send ") {
// Direct send to one member: `/send <user> <path>`. Everyone receives the
// broadcast offer, but only <user> is prompted to /accept.
let rest = rest.trim();
match rest.split_once(char::is_whitespace) {
Some((who, path)) => {
let (who, path) = (who.trim(), path.trim());
if who == app.me {
app.sys("can't /send to yourself — use /sendroom <path> for everyone");
} else if path.is_empty() {
app.sys("usage: /send <user> <path> · /sendroom <path> for everyone");
} else if !app.users.iter().any(|u| u.username == who) {
let roster = app
.users
.iter()
.map(|u| u.username.as_str())
.filter(|u| *u != app.me)
.collect::<Vec<_>>()
.join(" · ");
app.err(format!("no member '{who}' in the room — try: {roster}"));
} else {
offer_payload(app, send_seq, out_tx, room, app_tx, path, Some(who));
}
}
None => app.sys("usage: /send <user> <path> · /sendroom <path> for everyone"),
}
} else if line == "/accept" {
if let Some(o) = app.pending_offer.take() {
send_frame(out_tx, room, json!({"_ft":"accept","id": o.id}));
if let Some(t) = app.transfers.get_mut(&o.id) {
t.accepted = true;
}
app.sys(format!("accepting {}…", o.name));
} else {
app.sys("no pending offer");
}
} else if line == "/reject" {
if let Some(o) = app.pending_offer.take() {
send_frame(out_tx, room, json!({"_ft":"reject","id": o.id}));
app.transfers.remove(&o.id);
app.sys("rejected the offer");
} else {
app.sys("no pending offer");
}
} else if let Some(rest) = line.strip_prefix("/sbx") {
let mut p = rest.split_whitespace();
match p.next() {
// Grammar: `/sbx <vm type> <option>` — the backend leads, e.g.
// `/sbx podman`, `/sbx docker`, `/sbx multipass`, `/sbx local`.
// vbox is the exception that takes extra options (a VM name, `new`,
// confirm): `/sbx vbox gui <vm>`, `/sbx vbox new [name]`.
// `launch` stays accepted as a transitional alias (`/sbx launch
// <backend> …`) so older muscle memory and help strings keep working.
Some(
sub @ ("launch" | "docker" | "podman" | "multipass" | "local" | "vbox"
| "virtualbox"),
) => {
// `--start` (alias `--start-daemon` / `-y`) opts in to booting a
// stopped Docker daemon; everything else is positional. The first
// positional selects the backend: docker | podman | multipass |
// vbox | local. Each runs on the *invoker's* own machine.
let mut args: Vec<&str> = p.collect();
// Backend-led form: the matched token IS the backend, so push it to
// the front of the positional args where the `first` logic below
// expects it. The `launch` alias leaves the backend in `args` as-is.
if sub != "launch" {
args.insert(0, sub);
}
let start_daemon = args
.iter()
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
// Consent token: if the selected backend's binary is missing,
// `install` opts in to installing it (detect-then-install). It's
// a positional keyword (not the image), so filter it out below.
let want_install = args.iter().any(|a| matches!(*a, "install" | "--install"));
// `gui` is only meaningful for the vbox path (`/sbx vbox gui <vm>`);
// it's a keyword, not an image, so strip it from the positionals like
// `install` so the VM name is the next positional.
let mut pos = args
.iter()
.copied()
.filter(|a| !a.starts_with('-') && !matches!(*a, "install" | "gui"));
let first = pos.next();
if matches!(first, Some("virtualbox") | Some("vbox")) {
// VirtualBox runs locally in its own GUI — host & guest each
// open their own copy, nothing is relayed. It's independent of
// the shared-PTY sandbox, so it bypasses the "already running"
// guard. Grammar: `/sbx vbox [gui] <vm> [yes]`; a bare
// `/sbx vbox` opens the arrow-navigable VM picker.
let mut rest = pos.peekable();
if rest.peek() == Some(&"new") {
// `/sbx vbox new [name]` — build a brand-new VM from
// a cloud image (cloud-init toolchain), not an existing one.
rest.next();
let name = rest.next().unwrap_or("hh-vbox").to_string();
launch_vbox_new(app, name, app.me.clone(), app_tx);
} else {
// The optional `gui` keyword is already stripped from `pos`
// (it's a global option filter), so the next positional is
// the VM name directly.
match rest.next() {
Some(vm) => {
let confirmed = rest
.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
}
None => open_vbox_picker(app),
}
}
} else if app.sandbox.is_some() || broker.is_some() || *launching {
app.sys("a sandbox is already running");
} else {
let backend = first
.and_then(sbx::Backend::parse)
.unwrap_or(sbx::Backend::Local);
let image = pos
.next()
.map(str::to_string)
.unwrap_or_else(|| backend.default_image().to_string());
// Is this backend's binary present? Docker/Podman/Multipass can
// be installed on consent; Local needs nothing and vbox is
// handled in its own branch above. Podman is daemonless and
// rootless, so unlike Docker it skips the daemon/sudo gates
// below entirely (those are guarded by `== Backend::Docker`).
let installed = match backend {
sbx::Backend::Docker => sbx::docker_installed(),
sbx::Backend::Podman => sbx::podman_installed(),
sbx::Backend::Multipass => sbx::multipass_installed(),
_ => true,
};
let token = first.unwrap_or("docker");
if !installed && !want_install {
app.err(format!(
"{} is not installed — retry with `/sbx {token} install` to install it (needs sudo)",
backend.label()
));
} else if installed
&& backend == sbx::Backend::Docker
&& !start_daemon
&& !sbx::docker_daemon_up()
{
app.err("docker daemon is not running — retry with `/sbx docker --start` to boot it (sudo), or run ./scripts/ensure-docker.sh in a terminal first");
} else {
// install_first ⇒ binary missing + consent given: install
// it off-thread before provisioning (a fresh Docker
// install also leaves its daemon up).
let install_first = !installed;
// Installing Docker needs root; booting its daemon needs
// root too — *except* Docker Desktop, whose engine starts
// via a per-user systemd unit with no sudo at all.
let needs_sudo = install_first
|| (start_daemon
&& backend == sbx::Backend::Docker
&& !sbx::docker_daemon_up()
&& !sbx::docker_desktop());
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_grid(sz.0, sz.1, &app.layout);
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
if needs_sudo && !sbx::sudo_ready() {
// Root is needed but sudo isn't cached. sudo can't prompt
// on the tty from inside the raw-mode TUI, so capture the
// password in a masked, local-only modal and feed it to
// `sudo -S`. The launch fires on submit (see the run loop).
app.sudo_prompt = Some(SudoPrompt {
password: String::new(),
pending: PendingPrivileged::Launch(PendingSudoLaunch {
backend,
image,
members,
rows,
cols,
start_daemon,
install_first,
}),
});
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
} else {
*launching = true;
if install_first {
app.sys(format!(
"installing {} (needs sudo)… then summoning the sandbox",
backend.label()
));
} else {
app.sys(format!(
"summoning {} sandbox… (provisioning unix users; multipass boot ~30s)",
backend.label()
));
}
spawn_launch(
backend,
image,
app.me.clone(),
members,
rows,
cols,
start_daemon,
install_first,
None,
pty_tx.clone(),
broker_tx.clone(),
app_tx.clone(),
);
}
}
}
}
Some("stop") => {
if let Some(mut sb) = broker.take() {
sb.stop();
if let Some((be, name)) = broker_meta.take() {
tokio::task::spawn_blocking(move || sbx::teardown(be, &name));
}
*announced_dims = None;
send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"}));
} else {
app.sys("you are not hosting a sandbox");
}
}
Some("save") => {
// `--local` (alias `-l`) also writes a portable, standalone copy
// under hh-snapshots/ (a docker `.tar`) the saver fully owns; the
// default keeps just the in-backend snapshot. The label is the
// first non-flag arg.
let args: Vec<&str> = p.collect();
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
let label = args
.iter()
.copied()
.find(|a| !a.starts_with('-'))
.unwrap_or("snap")
.to_string();
if !is_snap_label(&label) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else if let Some((be, name)) = broker_meta.clone() {
app.sys(format!(
"saving sandbox state as '{label}'{}…",
if local { " (+ local copy)" } else { "" }
));
// Docker commits a *live* container, so its save is non-
// disruptive. Multipass can only snapshot a powered-off
// instance, so saving it necessarily stops the shared shell —
// tear the live session down (same as `/sbx stop`, but WITHOUT
// purging: the instance must survive so the snapshot does too).
if be == sbx::Backend::Multipass {
if let Some(mut sb) = broker.take() {
sb.stop();
}
broker_meta.take();
*announced_dims = None;
send_frame(out_tx, room, json!({"_sbx":"status","state":"stopped"}));
app.sys("multipass must power off to snapshot — stopping the shared shell, then saving (reload it with `/sbx load`)");
}
let (tx, lbl) = (app_tx.clone(), label.clone());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::save_state(be, &name, &label, local)).await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!(
"† saved sandbox → {desc} · reload with `/sbx load {lbl}`"))),
Ok(Err(e)) => tx.send(Net::Err(format!("save failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("save task: {e}"))),
};
});
} else {
app.sys("only the sandbox host can /sbx save (launch one first)");
}
}
Some("load") => match p.next() {
None => app.sys("usage: /sbx load <label> (a docker, podman or multipass snapshot saved via /sbx save)"),
Some(label) if !is_snap_label(label) => {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
}
Some(label) => {
if app.sandbox.is_some() || broker.is_some() || *launching {
app.sys("stop the current sandbox first (`/sbx stop`) before loading a snapshot");
} else {
// A bare label is backend-ambiguous, so probe which backend
// actually holds the snapshot, then restore through it:
// docker reruns a committed image; multipass restores the
// (stopped, preserved) instance and re-attaches its shell.
let label = label.to_string();
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_grid(sz.0, sz.1, &app.layout);
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
let owner = app.me.clone();
app.sys(format!("loading snapshot '{label}'…"));
let (pty, btx, atx) =
(pty_tx.clone(), broker_tx.clone(), app_tx.clone());
tokio::spawn(async move {
let (n, lbl) = (SBX_NAME.to_string(), label.clone());
let kind = tokio::task::spawn_blocking(move || {
sbx::locate_snapshot(&n, &lbl)
})
.await
.unwrap_or(sbx::SnapKind::None);
match kind {
sbx::SnapKind::Docker => {
if !sbx::docker_daemon_up() {
let _ = atx.send(Net::Err("docker daemon is not running — `/sbx docker --start` once to boot it, then retry".into()));
let _ = btx.send(BrokerMsg::Failed);
return;
}
let image = format!("{}:{}", sbx::SNAP_REPO, label);
let _ = atx.send(Net::Sys(format!("loading docker sandbox from {image}…")));
spawn_launch(
sbx::Backend::Docker, image, owner, members, rows,
cols, false, false, None, pty, btx, atx,
);
}
sbx::SnapKind::Podman => {
// Podman is daemonless — no daemon-up check; the
// committed image reruns directly via the engine.
let image = format!("{}:{}", sbx::SNAP_REPO, label);
let _ = atx.send(Net::Sys(format!("loading podman sandbox from {image}…")));
spawn_launch(
sbx::Backend::Podman, image, owner, members, rows,
cols, false, false, None, pty, btx, atx,
);
}
sbx::SnapKind::Multipass => {
let lbl = label.clone();
let res = tokio::task::spawn_blocking(move || {
sbx::mp_restore(SBX_NAME, &lbl)
})
.await;
match res {
Ok(Ok(desc)) => {
let _ = atx.send(Net::Sys(format!("† {desc} · booting…")));
spawn_launch(
sbx::Backend::Multipass,
sbx::Backend::Multipass.default_image().to_string(),
owner, members, rows, cols, false, false, None, pty, btx, atx,
);
}
Ok(Err(e)) => {
let _ = atx.send(Net::Err(format!("load failed: {e}")));
let _ = btx.send(BrokerMsg::Failed);
}
Err(e) => {
let _ = atx.send(Net::Err(format!("load task: {e}")));
let _ = btx.send(BrokerMsg::Failed);
}
}
}
sbx::SnapKind::None => {
let _ = atx.send(Net::Err(format!("no saved snapshot '{label}' — list with `/sbx snaps`")));
let _ = btx.send(BrokerMsg::Failed);
}
}
});
}
}
},
Some("snaps") | Some("snapshots") => {
let be = broker_meta
.as_ref()
.map(|(b, _)| *b)
.unwrap_or(sbx::Backend::Docker);
let (tx, name) = (app_tx.clone(), SBX_NAME.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::list_snapshots(be, &name)).await;
let _ = match res {
Ok(Ok(v)) if !v.is_empty() => {
tx.send(Net::Sys(format!("saved snapshots: {}", v.join(", "))))
}
Ok(Ok(_)) => tx.send(Net::Sys(
"no saved snapshots yet — `/sbx save [label]` to make one".into())),
Ok(Err(e)) => tx.send(Net::Err(format!("snaps: {e}"))),
Err(e) => tx.send(Net::Err(format!("snaps task: {e}"))),
};
});
}
Some("vms") => {
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(|| {
let ver = sbx::vbox_version().ok_or_else(|| {
"VirtualBox isn't installed — install it with `/sbx gui <vm> --install`, or run ./scripts/ensure-vbox.sh".to_string()
})?;
let vms = sbx::list_vms().map_err(|e| e.to_string())?;
let running = sbx::running_vms();
Ok::<_, String>((ver, vms, running))
})
.await;
let _ = match res {
Ok(Ok((ver, v, running))) if !v.is_empty() => tx.send(Net::Sys(format!(
"VirtualBox {ver} detected · VMs: {}",
v.iter()
.map(|n| if running.iter().any(|r| r == n) {
format!("{n} ▶")
} else {
n.clone()
})
.collect::<Vec<_>>()
.join(", ")
))),
Ok(Ok((ver, _, _))) => tx.send(Net::Sys(format!(
"VirtualBox {ver} detected · no VMs registered"
))),
Ok(Err(e)) => tx.send(Net::Err(e)),
Err(e) => tx.send(Net::Err(format!("vms task: {e}"))),
};
});
}
Some("vmsave") => {
// Snapshot a local VirtualBox VM. `--local` (alias `-l`) also
// exports a portable `.ova` appliance under hh-snapshots/.
let args: Vec<&str> = p.collect();
let local = args.iter().any(|a| matches!(*a, "--local" | "-l"));
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
match pos.next() {
None => app.sys(
"usage: /sbx vmsave <vm> [label] [--local] (snapshot a VirtualBox VM; list VMs with /sbx vms)",
),
Some(vm) => {
let label = pos.next().unwrap_or("snap").to_string();
if !is_snap_label(&label) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else {
app.sys(format!(
"snapshotting VM '{vm}' as '{label}'{}…",
if local { " (+ local .ova)" } else { "" }
));
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::vm_save_state(&vm, &label, local)
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("† saved VM → {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vmsave failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmsave task: {e}"))),
};
});
}
}
}
}
Some("vmsnaps") => {
match p.next() {
None => app.sys("usage: /sbx vmsnaps <vm> (list a VirtualBox VM's snapshots)"),
Some(vm) => {
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || sbx::vm_snapshots(&vm)).await;
let _ = match res {
Ok(Ok(v)) if !v.is_empty() => {
tx.send(Net::Sys(format!("VM snapshots: {}", v.join(", "))))
}
Ok(Ok(_)) => tx.send(Net::Sys(
"no VM snapshots yet — `/sbx vmsave <vm> [label]` to make one".into())),
Ok(Err(e)) => tx.send(Net::Err(format!("vmsnaps: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmsnaps task: {e}"))),
};
});
}
}
}
Some("vmload") => {
// Restore a VirtualBox VM to a snapshot (inverse of /sbx vmsave)
// then boot its GUI locally. `<label>` picks a named snapshot;
// omit it to restore the VM's current snapshot.
let mut vpos = p.filter(|a| !a.starts_with('-'));
match vpos.next() {
None => app.sys(
"usage: /sbx vmload <vm> [label] (restore a VirtualBox snapshot + boot; list with /sbx vmsnaps <vm>)",
),
Some(vm) => {
let label = vpos.next().map(str::to_string);
if label.as_deref().is_some_and(|l| !is_snap_label(l)) {
app.sys("snapshot label must be alphanumerics, '.', '_' or '-'");
} else {
app.sys(format!(
"restoring VM '{vm}'{} then booting…",
label
.as_deref()
.map(|l| format!(" to '{l}'"))
.unwrap_or_default()
));
let (tx, vm) = (app_tx.clone(), vm.to_string());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
let desc = sbx::vm_restore(&vm, label.as_deref())?;
let boot = sbx::gui_launch(&vm)?;
Ok::<_, anyhow::Error>(format!("{desc} · {boot}"))
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("† {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vmload failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmload task: {e}"))),
};
});
}
}
}
}
Some("vmlib") => {
// VM library: a catalog of pointers to installable VMs (no
// images are bundled). Bare `/sbx vmlib` lists the catalog
// marking which are already installed locally; `/sbx vmlib <id>`
// shows the pointer/notes; `/sbx vmlib <id> install [--iso path]`
// builds the chosen VM locally on the caller's own machine.
let args: Vec<&str> = p.collect();
let iso = args
.iter()
.position(|a| *a == "--iso")
.and_then(|i| args.get(i + 1).copied())
.map(str::to_string);
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
match pos.next() {
None => {
// Catalog listing
let tx = app_tx.clone();
tokio::spawn(async move {
let res =
tokio::task::spawn_blocking(sbx::vbox_library).await;
let _ = match res {
Ok(Ok(vms)) => {
let body = vms
.iter()
.map(|v| {
let mark = if v.installed { "✓" } else { "↓" };
format!("{mark} {}{} ({})", v.id, v.name, v.os)
})
.collect::<Vec<_>>()
.join("\n");
tx.send(Net::Sys(format!(
"VM library (✓ installed · ↓ available) — `/sbx vmlib <id>` for details, `/sbx vmlib <id> install` to build locally:\n{body}"
)))
}
Ok(Err(e)) => tx.send(Net::Err(format!("vmlib: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmlib task: {e}"))),
};
});
}
Some(id) => {
let id = id.to_string();
let action = pos.next();
if matches!(action, Some("install")) {
app.sys(format!(
"building VM '{id}' locally{}… (this downloads + provisions on your own machine; watch for a VirtualBox window)",
iso.as_deref().map(|p| format!(" from {p}")).unwrap_or_default()
));
let (tx, iso) = (app_tx.clone(), iso.clone());
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::vbox_library_install(&id, iso)
})
.await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("† {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vmlib install failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vmlib install task: {e}"))),
};
});
} else {
// Info / pointer for one entry
let tx = app_tx.clone();
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
sbx::library_vm(&id).ok_or(id)
})
.await;
let _ = match res {
Ok(Ok(v)) => {
let state = if v.installed {
"already installed".to_string()
} else {
"available — `/sbx vmlib <id> install` to build locally".to_string()
};
let mut body = format!(
"{} ({}) · {} · {}\n{}",
v.name, v.id, v.os, v.size, state
);
if !v.notes.is_empty() {
body.push_str(&format!("\n{}", v.notes));
}
if !v.page.is_empty() {
body.push_str(&format!("\nsource: {}", v.page));
}
tx.send(Net::Sys(body))
}
Ok(Err(bad)) => tx.send(Net::Err(format!(
"no VM library entry '{bad}' — `/sbx vmlib` lists the catalog"
))),
Err(e) => tx.send(Net::Err(format!("vmlib task: {e}"))),
};
});
}
}
}
}
Some("gui") => {
// Convenience alias for `/sbx vbox gui <vm> [yes]` — opens a
// local VirtualBox VM's GUI on your own machine. Bare `/sbx gui`
// opens the VM picker, same as `/sbx vbox`.
let mut gpos = p.filter(|a| !a.starts_with('-'));
match gpos.next() {
Some(vm) => {
let confirmed =
gpos.any(|t| matches!(t, "yes" | "y" | "go" | "confirm" | "ok"));
launch_vbox_gui(app, vm.to_string(), confirmed, app_tx, out_tx, room);
}
None => open_vbox_picker(app),
}
}
other => {
let usage = "usage: /sbx <type> <option> — /sbx docker|podman|multipass|local [image] · /sbx vbox [gui] <vm> [yes] (host opens directly; non-host appends yes) · /sbx vbox new [name] (fresh VM) · vmlib [<id> [install [--iso path]]] (VM library — build locally) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>";
// Bare `/sbx` → usage. An unrecognised subcommand → suggest the
// closest documented one before the usage line.
match other.and_then(|bad| closest(bad, SBX_SUBCOMMANDS).map(|s| (bad, s))) {
Some((bad, s)) => {
app.sys(format!("unknown `/sbx {bad}` — did you mean `/sbx {s}`? {usage}"))
}
None => app.sys(usage.to_string()),
}
}
}
} else if let Some(rest) = line.strip_prefix("/unsudo") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the owner can /unsudo");
} else if target.is_empty() {
app.sys("usage: /unsudo <user>");
} else if let Some((be, name)) = broker_meta.clone() {
app.sudoers.remove(target);
let (t, n) = (target.to_string(), name);
tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, false));
broadcast_acl(out_tx, room, app);
app.sys(format!("revoked sudo from {target} in the VM"));
} else {
app.sys("no sandbox running");
}
} else if let Some(rest) = line.strip_prefix("/sudo") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the owner can delegate sudo");
} else if target.is_empty() {
app.sys("usage: /sudo <user> (delegate VM superuser) | /unsudo <user>");
} else if let Some((be, name)) = broker_meta.clone() {
app.sudoers.insert(target.to_string());
let (t, n) = (target.to_string(), name);
tokio::task::spawn_blocking(move || sbx::set_sudo(be, &n, &t, true));
broadcast_acl(out_tx, room, app);
app.sys(format!("delegated VM superuser (sudo) to {target}"));
} else {
app.sys("no sandbox running");
}
} else if let Some(rest) = line.strip_prefix("/grant") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /grant");
} else if target.is_empty() {
app.sys("usage: /grant <user> (or /grant ai for every AI agent)");
} else if target == "ai" {
// Grant drive to every AI agent in the room in one shot, so the owner
// never has to name each model. Intersect the known-AI set with the
// live roster so departed agents are skipped.
let agents: Vec<String> = app
.users
.iter()
.map(|u| u.username.clone())
.filter(|n| app.ai_agents.contains(n))
.collect();
if agents.is_empty() {
app.sys("no AI agents in the room to grant — /ai start one first");
} else {
for n in &agents {
app.drivers.insert(n.clone());
}
broadcast_acl(out_tx, room, app);
app.sys(format!("granted drive to all AI agents: {}", agents.join(", ")));
}
} else {
app.drivers.insert(target.to_string());
broadcast_acl(out_tx, room, app);
app.sys(format!("granted drive to {target}"));
}
} else if let Some(rest) = line.strip_prefix("/revoke") {
let target = rest.trim();
if !app.is_owner() {
app.sys("only the sandbox owner can /revoke");
} else if target == app.me {
app.sys("the owner cannot revoke themselves");
} else if target.is_empty() {
app.sys("usage: /revoke <user>");
} else {
app.drivers.remove(target);
broadcast_acl(out_tx, room, app);
app.sys(format!("revoked drive from {target}"));
}
} else if line == "/ai stop" {
// Reap a child that already exited (e.g. failed auth) so the message is honest.
if agent
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
{
*agent = None;
}
if let Some(mut child) = agent.take() {
let _ = child.kill();
let _ = child.wait();
app.sys("† dismissed the AI agent");
// Drop any sandbox drive the agent held so a dead handle can't act.
app.agent_sbx_allow = false;
let revoked = app
.agent_name
.take()
.is_some_and(|n| app.drivers.remove(&n) | app.sudoers.remove(&n));
if revoked && app.sandbox.is_some() {
broadcast_acl(out_tx, room, app);
}
} else {
app.sys("no AI agent was started from this client");
}
} else if let Some(rest) = line
.strip_prefix("/ai start")
.filter(|r| r.is_empty() || r.starts_with(' '))
{
// Drop a handle to an agent that has already exited so we can restart.
if agent
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
{
*agent = None;
}
if agent.is_some() {
app.sys("an AI agent is already running from this client — /ai stop first");
} else {
// Trailing flag words (any order): `allow` auto-grants the agent
// sandbox drive on launch; the harness word `native` (default) or
// `simple` picks the granted-`!task` harness. `plain` is a back-compat
// alias for `simple`.
let mut raw = rest.trim();
let mut grant_sbx = false;
let mut harness: Option<&str> = None;
loop {
if let Some(head) = raw
.strip_suffix("allow")
.filter(|h| h.is_empty() || h.ends_with(' '))
{
grant_sbx = true;
raw = head.trim();
continue;
}
if let Some((word, head)) = ["native", "simple", "plain"]
.iter()
.find_map(|w| {
raw.strip_suffix(w)
.filter(|h| h.is_empty() || h.ends_with(' '))
.map(|h| (*w, h))
})
{
harness = Some(if word == "native" { "native" } else { "simple" });
raw = head.trim();
continue;
}
break;
}
// A bare name (no ':' tag, no '/' path) is a models.toml profile;
// anything else is treated as a literal Ollama model tag.
let (profile, model): (Option<&str>, &str) = if raw.is_empty() {
(None, "qwen2.5:3b")
} else if raw.contains(':') || raw.contains('/') {
(None, raw)
} else {
(Some(raw), raw)
};
// Name the agent after what it runs, for clarity in the roster: a
// profile keeps its label; a direct Ollama model uses its tag
// (e.g. "qwen2.5:3b" — model name + parameter size).
let name = profile.unwrap_or(model);
match spawn_agent(params, &app.password, name, profile, model, harness) {
Ok(child) => {
*agent = Some(child);
app.agent_name = Some(name.to_string());
app.agent_sbx_allow = grant_sbx;
let desc = match profile {
Some(p) => format!("profile {p}"),
None => format!("ollama/{model}"),
};
let hdesc = match harness {
Some(h) => format!(", {h} harness"),
None => String::new(),
};
app.sys(format!(
"† summoning {name} ({desc}{hdesc})… it will announce when online"
));
if grant_sbx {
// Grant now if a sandbox is already running; otherwise the
// Ready handler applies it when one launches.
if app.sandbox.is_some() && app.owner.as_deref() == Some(app.me.as_str()) {
app.drivers.insert(name.to_string());
broadcast_acl(out_tx, room, app);
}
app.sys(format!(
"† {name} will get sandbox drive — Ctrl-X kills all drive in a pinch"
));
}
}
Err(e) => app.err(format!("/ai start failed: {e}")),
}
}
} else if line == "/ai list" || line == "/ai models" {
// Reap an agent that already exited so we don't forward into a dead pipe.
if agent
.as_mut()
.is_some_and(|c| matches!(c.try_wait(), Ok(Some(_))))
{
*agent = None;
}
if agent.is_some() {
// A live agent answers these itself (canned, zero model-call).
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
} else if line == "/ai list" {
app.sys("no AI agent running from this client — /ai start to summon one");
} else {
// No agent: still useful to show what could be started locally.
app.sys("querying local ollama…");
let tx = app_tx.clone();
tokio::task::spawn_blocking(move || {
let msg = match local_ollama_models() {
Ok(ms) if !ms.is_empty() => format!(
"local ollama models (start one with `/ai start <name>`): {}",
ms.join(", ")
),
Ok(_) => "ollama is reachable but has no models pulled — \
`ollama pull qwen2.5:3b` or run ./scripts/bootstrap-ai.sh"
.to_string(),
Err(_) => "ollama not reachable at localhost:11434 — run \
./scripts/bootstrap-ai.sh, or `/ai start <profile>` for a cloud model"
.to_string(),
};
let _ = tx.send(Net::Sys(msg));
});
}
} else if line.starts_with('/') {
// Leading slash but no command branch matched. Either a known command
// family that intentionally falls through to chat (notably `/ai
// <question>`, which a running agent reads from the room), or a typo we
// should help with instead of silently broadcasting as a chat message.
let cmd = line.split_whitespace().next().unwrap_or(line);
if KNOWN_COMMANDS.contains(&cmd) {
if app.connected {
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
}
} else if let Some(s) = closest(cmd, KNOWN_COMMANDS) {
app.sys(format!("unknown command {cmd} — did you mean `{s}`? (/help lists all)"));
} else {
app.sys(format!("unknown command {cmd} — /help lists all commands"));
}
} else if !line.is_empty() && app.connected {
let _ = out_tx.send(WsMsg::Text(room.encrypt(line.as_bytes())));
}
}
/// Probe the local Ollama daemon for installed model tags. Used to answer
/// `/ai models` before any agent is summoned (the agentless path); a running
/// agent answers in-room instead. Honors `$OLLAMA_HOST`.
fn local_ollama_models() -> Result<Vec<String>, String> {
let host = std::env::var("OLLAMA_HOST")
.ok()
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let host = host.trim_end_matches('/');
let url = format!("{host}/api/tags");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(1500))
.build()
.map_err(|e| e.to_string())?;
let body: serde_json::Value = client
.get(&url)
.send()
.map_err(|e| e.to_string())?
.json()
.map_err(|e| e.to_string())?;
let models = body
.get("models")
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.get("name").and_then(|n| n.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Ok(models)
}
/// Every top-level slash command (the leading token). Used both to recognise a
/// known command family that legitimately falls through to chat (e.g. `/ai
/// <question>`) and as the candidate set for the "did you mean" suggester.
const KNOWN_COMMANDS: &[&str] = &[
"/help", "/?", "/clear", "/cls", "/pw", "/password", "/theme", "/layout", "/drive",
"/sendroom", "/send", "/accept", "/reject", "/sbx", "/unsudo", "/sudo", "/grant", "/revoke",
"/ai",
];
/// Canonical `/sbx` subcommands (backends + actions) for the subcommand-level
/// "did you mean". Aliases (`launch`, `virtualbox`, `snapshots`, `gui`) are
/// omitted so suggestions point at the documented form.
const SBX_SUBCOMMANDS: &[&str] = &[
"docker", "podman", "multipass", "local", "vbox", "stop", "save", "load", "snaps", "vms",
"vmsave", "vmload", "vmsnaps", "vmlib",
];
/// Classic Levenshtein edit distance (insert/delete/substitute, each cost 1).
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
/// The nearest candidate to `input` by edit distance, if one is close enough to
/// be a plausible typo: distance ≤ 3 and strictly less than the input length (so
/// a tiny stray token doesn't "match" everything). Case-insensitive; ties go to
/// the first candidate. Returns `None` when nothing is close.
fn closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
let input = input.to_ascii_lowercase();
let len = input.chars().count();
candidates
.iter()
.map(|c| (levenshtein(&input, &c.to_ascii_lowercase()), *c))
.filter(|(d, _)| *d <= 3 && *d < len)
.min_by_key(|(d, _)| *d)
.map(|(_, c)| c)
}
/// A safe snapshot label — compatible with both a Docker image tag and a
/// multipass snapshot name (alphanumerics plus `.`, `_`, `-`).
fn is_snap_label(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
/// Find a local VirtualBox appliance (`.ova`/`.ovf`) for `vm` so a non-host can
/// import ("pull") it. Looks in `hh-snapshots/` (where `/sbx vmsave --local`
/// writes exports) and `./downloads/` (where a /sent appliance lands). Matches
/// on the file stem equalling, prefixing (`<vm>-…`), or containing the VM name.
fn find_local_appliance(vm: &str) -> Option<std::path::PathBuf> {
let dirs = [
std::path::PathBuf::from(sbx::SNAP_DIR),
std::path::PathBuf::from("./downloads"),
];
for dir in dirs {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
let ext_ok = path
.extension()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case("ova") || s.eq_ignore_ascii_case("ovf"))
.unwrap_or(false);
let name_match = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s == vm || s.starts_with(&format!("{vm}-")) || s.contains(vm))
.unwrap_or(false);
if ext_ok && name_match {
return Some(path);
}
}
}
None
}
/// Open the arrow-navigable VM picker (populated synchronously — a single
/// `VBoxManage list vms`, same blocking style as the other vbox probes here).
/// Empty/uninstalled cases steer the user instead of opening an empty list.
fn open_vbox_picker(app: &mut App) {
if !sbx::vbox_installed() {
app.sys("VirtualBox isn't installed — `/sbx vbox gui <vm> yes` installs it, or run ./scripts/ensure-vbox.sh");
return;
}
match sbx::list_vms() {
Ok(vms) if !vms.is_empty() => {
app.vbox_picker = Some(VboxPicker { vms, selected: 0 });
app.sys("† pick a VM — ↑/↓ move · Enter/Tab choose · Esc dismiss");
}
Ok(_) => app.sys(
"no VirtualBox VMs registered — a host can /send you a .ova, then `/sbx vbox gui <vm> yes` to import it",
),
Err(e) => app.err(format!("listing VMs: {e}")),
}
}
/// Launch (or pull-then-launch) a local VirtualBox VM's GUI on the caller's OWN
/// machine — nothing is relayed; every member opens their own copy. Shared by
/// `/sbx vbox gui <vm>` and the `/sbx gui <vm>` alias.
///
/// Frictionless for a "host" who already has VirtualBox AND the VM imported with
/// no other VM holding VT-x: it just boots. A non-host missing VirtualBox or the
/// VM image — or who'd have to stop another VM first — must re-issue with a
/// trailing `yes`, which then installs VirtualBox, imports the shared appliance,
/// and/or frees VT-x before booting.
/// Build + boot a brand-new VirtualBox VM from a cloud image (the only path that
/// creates a VM from scratch — `launch_vbox_gui` opens existing ones). The heavy
/// lifting (image download, cloud-init seed, VBox create/boot) is in
/// scripts/vbox-new.sh; we run it off-thread and report through `app_tx` so the
/// first-run ~600MB download never stalls the TUI.
fn launch_vbox_new(app: &mut App, name: String, user: String, app_tx: &UnboundedSender<Net>) {
if !is_snap_label(&name) {
app.sys("VM name must be alphanumerics, '.', '_' or '-'");
return;
}
if !sbx::vbox_installed() {
app.sys("VirtualBox isn't installed — `/sbx gui <vm> --install` or run ./scripts/ensure-vbox.sh first");
return;
}
if sbx::vm_registered(&name) {
app.sys(format!(
"a VM named {name} already exists — pick another name, or boot it with `/sbx gui {name}`"
));
return;
}
app.sys(format!(
"building fresh VirtualBox VM {name} (first run downloads a ~600MB Ubuntu cloud image; cloud-init installs the toolchain on boot)…"
));
let tx = app_tx.clone();
tokio::spawn(async move {
let (n, u) = (name.clone(), user);
let res = tokio::task::spawn_blocking(move || sbx::vbox_new(&n, &u)).await;
let _ = match res {
Ok(Ok(desc)) => tx.send(Net::Sys(format!("† {desc}"))),
Ok(Err(e)) => tx.send(Net::Err(format!("vbox new failed: {e}"))),
Err(e) => tx.send(Net::Err(format!("vbox new task: {e}"))),
};
});
}
fn launch_vbox_gui(
app: &mut App,
vm: String,
confirmed: bool,
app_tx: &UnboundedSender<Net>,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
) {
let installed = sbx::vbox_installed();
if installed && sbx::vm_running(&vm) {
app.sys(format!(
"{vm} is already running — look for its window on your desktop"
));
return;
}
let have_vm = installed && sbx::vm_registered(&vm);
let conflicts = sbx::vtx_holders();
let needs_install = !installed;
let needs_import = !have_vm;
// Host path: VirtualBox + this VM already present and nothing to stop → boot
// immediately, no confirmation.
if installed && have_vm && conflicts.is_empty() {
spawn_vm_execute(vm, conflicts, false, false, app, app_tx, out_tx, room, None);
return;
}
// Otherwise a side effect is required (install / import / stop another VM).
// The non-host opts in with a trailing `yes`.
if !confirmed {
let mut steps: Vec<String> = Vec::new();
if needs_install {
steps.push("install VirtualBox (needs sudo)".to_string());
}
if needs_import {
match find_local_appliance(&vm) {
Some(p) => steps.push(format!("import {}", p.display())),
None => {
app.sys(format!(
"no local appliance for {vm} yet — have the host export it (`/sbx vmsave {vm} --local`) and /send you the .ova, then `/sbx vbox gui {vm} yes`"
));
return;
}
}
}
if !conflicts.is_empty() {
steps.push(format!("stop {} other VM(s) holding VT-x", conflicts.len()));
}
app.sys(format!(
"opening {vm} on YOUR machine will {}. → `/sbx vbox gui {vm} yes` to proceed",
steps.join(", ")
));
return;
}
// Confirmed. Refuse VT-x holders we can't cleanly restart rather than kill them.
if let Some(bad) = conflicts.iter().find(|h| !h.stoppable) {
app.err(format!(
"can't free VT-x automatically — {} is running and I won't kill it. Stop it yourself, then retry `/sbx vbox gui {vm} yes`.",
bad.label
));
return;
}
// Installing VirtualBox needs root. If creds aren't cached, capture the
// password in the same masked, local-only modal the container path uses —
// sudo can't prompt on the tty from inside the raw-mode TUI. The install +
// VM boot fires on submit (see the run loop's sudo handler).
if needs_install && !sbx::sudo_ready() {
app.sudo_prompt = Some(SudoPrompt {
password: String::new(),
pending: PendingPrivileged::VboxInstall(PendingVboxInstall {
vm,
conflicts,
needs_install,
needs_import,
room: room.clone(),
}),
});
app.sys("🔒 sudo password needed to install VirtualBox — type it here (hidden), Enter to proceed · Esc cancels. Local only: never sent to the room.");
return;
}
spawn_vm_execute(vm, conflicts, needs_install, needs_import, app, app_tx, out_tx, room, None);
}
/// Final step of the gauntlet: off-thread, stop any VT-x holders (reversibly),
/// install VirtualBox if needed, then boot the VM's GUI. Reports through the
/// app channel so the blocking work never stalls the TUI. On success it also
/// broadcasts a `_sbx:vm` frame so the *other* party sees the shared appliance
/// go live and can `/sbx gui <vm>` their own local copy — anyone in the room may
/// do so (the per-client consent gauntlet *is* the client's permission; this is
/// deliberately not owner-gated, unlike `/sbx save`).
#[allow(clippy::too_many_arguments)] // launch context: vm + flags + 3 channels
fn spawn_vm_execute(
vm: String,
conflicts: Vec<sbx::VtxHolder>,
needs_install: bool,
needs_import: bool,
app: &mut App,
app_tx: &UnboundedSender<Net>,
out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>,
// sudo password captured by the masked modal when VBox install needed root
// and creds weren't cached. Fed to the install's `sudo -S` over stdin; `None`
// means creds are already cached (the install step uses `sudo -n`).
password: Option<String>,
) {
// Resolve the appliance path now (on the app thread) so the blocking task
// doesn't have to re-scan; None is fine unless an import actually proves
// necessary inside the task.
let appliance = if needs_import {
find_local_appliance(&vm)
} else {
None
};
if conflicts.is_empty() {
app.sys(format!("launching {vm}’…"));
} else {
app.sys(format!(
"freeing VT-x ({} VM(s)) then launching {vm}’…",
conflicts.len()
));
}
let tx = app_tx.clone();
let out = out_tx.clone();
let room = room.clone();
let announce_vm = vm.clone();
let pw = password; // moved into the blocking install step below
tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || {
for h in &conflicts {
sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?;
}
if needs_install && !sbx::vbox_installed() {
// VirtualBox install needs root. `pw` is `Some` when the masked
// modal captured a password (creds weren't cached) → fed to
// `sudo -S`; `None` means creds are cached → `sudo -n`. Either way
// it never hangs on a tty prompt the raw-mode TUI can't host.
sbx::ensure_vbox_install(pw).map_err(|e| format!("install failed: {e}"))?;
}
// Pull the shared appliance in if the VM still isn't registered.
if needs_import && !sbx::vm_registered(&vm) {
let ova = appliance.ok_or_else(|| {
format!("no local .ova appliance found for {vm} to import")
})?;
sbx::import_appliance(&ova).map_err(|e| format!("import failed: {e}"))?;
}
sbx::gui_launch(&vm).map_err(|e| e.to_string())
})
.await;
let _ = match res {
Ok(Ok(desc)) => {
// Tell the room the shared VM is live (others can open their own).
let frame = json!({"_sbx": "vm", "vm": announce_vm});
let _ = out.send(WsMsg::Text(room.encrypt(frame.to_string().as_bytes())));
tx.send(Net::Sys(format!("† {desc}")))
}
Ok(Err(e)) => tx.send(Net::Err(e)),
Err(e) => tx.send(Net::Err(format!("gui task: {e}"))),
};
});
}
#[allow(clippy::too_many_arguments)]
fn spawn_launch(
backend: sbx::Backend,
image: String,
owner: String,
members: Vec<String>,
rows: u16,
cols: u16,
start_daemon: bool,
install_first: bool,
// The sudo password captured by the in-TUI masked prompt, if escalation was
// needed. Local-only: it is fed to `sudo -S` via stdin inside the blocking
// install/prepare steps and never enters chat, the PTY, or any outbound frame.
password: Option<String>,
pty_tx: UnboundedSender<Vec<u8>>,
broker_tx: UnboundedSender<BrokerMsg>,
app_tx: UnboundedSender<Net>,
) {
tokio::spawn(async move {
// Optional install step: the backend's binary was missing and the user
// opted in. Runs before provisioning; failure aborts the launch (and
// clears the *launching guard via BrokerMsg::Failed).
if install_first {
let pw = password.clone();
let res = tokio::task::spawn_blocking(move || match backend {
sbx::Backend::Docker => sbx::ensure_docker_install(pw),
sbx::Backend::Podman => sbx::ensure_podman_install(pw),
sbx::Backend::Multipass => sbx::ensure_multipass_install(pw),
_ => Ok(()),
})
.await;
if let Err(e) = res.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Err(format!("install failed: {e:#}")));
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
}
let name = SBX_NAME.to_string();
let prep = {
let (n, img, pw) = (name.clone(), image.clone(), password.clone());
tokio::task::spawn_blocking(move || sbx::prepare(backend, &n, &img, start_daemon, pw))
.await
};
if let Err(e) = prep.unwrap_or_else(|e| Err(anyhow::anyhow!("join: {e}"))) {
let _ = app_tx.send(Net::Err(format!("sandbox prepare failed: {e:#}")));
let _ = broker_tx.send(BrokerMsg::Failed);
return;
}
// Provision real unix accounts (owner = sudoer) → the shell's run-user.
let run_user = {
let (n, o, ms) = (name.clone(), owner.clone(), members.clone());
tokio::task::spawn_blocking(move || sbx::provision(backend, &n, &o, &ms))
.await
.unwrap_or_default()
};
let (std_tx, std_rx) = std::sync::mpsc::channel::<Vec<u8>>();
match sbx::Sandbox::launch(backend, &name, &run_user, rows, cols, std_tx) {
Ok(sb) => {
std::thread::spawn(move || {
while let Ok(b) = std_rx.recv() {
if pty_tx.send(b).is_err() {
break;
}
}
});
let _ = broker_tx.send(BrokerMsg::Ready {
sb,
backend,
name,
rows,
cols,
});
}
Err(e) => {
let _ = app_tx.send(Net::Err(format!("sandbox launch failed: {e}")));
let _ = broker_tx.send(BrokerMsg::Failed);
}
}
});
}
/// Locate the repo root (the dir containing `cmd_chat/agent/`) by walking up from
/// the executable's path and the current directory — so `/ai start` finds the
/// Python agent whether the client runs from the checkout or its `target/` dir.
fn find_repo_root() -> Option<std::path::PathBuf> {
let mut starts: Vec<std::path::PathBuf> = Vec::new();
if let Ok(exe) = std::env::current_exe() {
starts.push(exe);
}
if let Ok(cwd) = std::env::current_dir() {
starts.push(cwd);
}
for start in starts {
let mut dir: Option<&std::path::Path> = Some(start.as_path());
while let Some(d) = dir {
if d.join("cmd_chat").join("agent").is_dir() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
}
None
}
/// Spawn the Python AI agent as a child process that joins this room as a normal
/// encrypted client (same SRP + room password). Returns the child handle so
/// `/ai stop` (and client quit) can kill it. The agent's stdout/stderr go to a
/// log file in the temp dir so its prints never corrupt the TUI.
fn spawn_agent(
params: &net::ConnParams,
password: &str,
name: &str,
profile: Option<&str>,
model: &str,
harness: Option<&str>,
) -> std::result::Result<std::process::Child, String> {
use std::process::{Command, Stdio};
let root = find_repo_root().ok_or_else(|| {
"can't locate the repo (cmd_chat/) — run the client from the checkout".to_string()
})?;
// Prefer the project venv's interpreter; fall back to HH_AI_PYTHON or python3.
let venv_py = root.join(".venv/bin/python");
let program = if venv_py.is_file() {
venv_py
} else {
std::path::PathBuf::from(std::env::var("HH_AI_PYTHON").unwrap_or_else(|_| "python3".into()))
};
let log_path = std::env::temp_dir().join(format!("hh-agent-{name}.log"));
let log = std::fs::File::create(&log_path)
.map_err(|e| format!("agent log {}: {e}", log_path.display()))?;
let log_err = log.try_clone().map_err(|e| e.to_string())?;
let mut cmd = Command::new(&program);
cmd.current_dir(&root)
.arg("-m")
.arg("cmd_chat.agent")
.arg(&params.ip)
.arg(params.port.to_string())
.arg("--name")
.arg(name);
// A profile carries its own provider/model/endpoint from models.toml;
// otherwise summon a local Ollama model by tag.
match profile {
Some(p) => {
cmd.arg("--profile").arg(p);
}
None => {
cmd.arg("--provider")
.arg("ollama")
.arg("--model")
.arg(model);
}
}
// Override the agent's default `!task` harness when the launcher asked for a
// specific one via `/ai start … native|simple` (plain aliases simple).
if let Some(h) = harness {
cmd.arg("--harness").arg(h);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(log_err));
if !password.is_empty() {
cmd.arg("--password").arg(password);
}
if params.no_tls {
cmd.arg("--no-tls");
}
if params.insecure {
cmd.arg("--insecure");
}
cmd.spawn()
.map_err(|e| format!("could not start agent ({}): {e}", program.display()))
}
#[cfg(test)]
mod tests {
use super::{drain_ready, App, Net, Role, User, VboxPicker};
use tokio::sync::mpsc::unbounded_channel;
/// The picker highlights the first VM and wraps a clamped selection; this is
/// the pure state the key handler drives with ↑/↓.
#[test]
fn vbox_picker_selection_is_clamped() {
let mut app = App::new("alice".into());
app.vbox_picker = Some(VboxPicker {
vms: vec!["WinLab".into(), "KaliBox".into()],
selected: 0,
});
let p = app.vbox_picker.as_mut().unwrap();
// moving up from the top stays at 0 (saturating)
p.selected = p.selected.saturating_sub(1);
assert_eq!(p.selected, 0);
// moving down is capped at the last index
p.selected = (p.selected + 1).min(p.vms.len() - 1);
p.selected = (p.selected + 1).min(p.vms.len() - 1);
assert_eq!(p.selected, 1, "selection must not run past the last VM");
}
/// A `_sbx:vm` broadcast from ANOTHER member surfaces a "open your own copy"
/// notice to the room.
#[test]
fn vm_opened_from_peer_notifies_the_room() {
let mut app = App::new("alice".into());
app.apply(Net::VmOpened {
by: "bob".into(),
vm: "WinLab".into(),
});
let last = &app.lines.last().unwrap().text;
assert!(last.contains("bob") && last.contains("WinLab"));
}
/// ...but our OWN echo is skipped — we already printed the local "launched"
/// line, so we must not double-report it.
#[test]
fn vm_opened_self_echo_is_skipped() {
let mut app = App::new("alice".into());
let before = app.lines.len();
app.apply(Net::VmOpened {
by: "alice".into(),
vm: "WinLab".into(),
});
assert_eq!(app.lines.len(), before, "self-echo must not add a line");
}
/// `drain_ready` pulls a bounded burst in FIFO order and stops at the cap.
#[tokio::test]
async fn drain_ready_is_fifo_and_capped() {
let (tx, mut rx) = unbounded_channel::<u32>();
for i in 0..1000 {
tx.send(i).unwrap();
}
// Mimic the loop: one awaited frame, then drain the ready burst.
let first = rx.recv().await.unwrap();
let mut buf = vec![first];
drain_ready(&mut rx, &mut buf, 256);
assert_eq!(buf.len(), 256, "burst must be capped");
assert_eq!(buf, (0..256).collect::<Vec<_>>(), "burst must stay FIFO");
}
/// An empty channel leaves the buffer untouched (no spurious items, no hang).
#[tokio::test]
async fn drain_ready_on_empty_is_a_noop() {
let (tx, mut rx) = unbounded_channel::<u32>();
tx.send(7).unwrap();
let first = rx.recv().await.unwrap();
let mut buf = vec![first];
drain_ready(&mut rx, &mut buf, 256);
assert_eq!(buf, vec![7]);
}
/// Regression for the "starting a sandbox stalls chat" bug: chat and a flood of
/// `_sbx:data` frames share one channel. Handling one frame per redraw would let
/// chat fall ~800 turns behind; batch draining must surface it within
/// ceil(801 / 256) = 4 turns no matter how hard the shell is scrolling.
#[tokio::test]
async fn chat_surfaces_promptly_under_sbx_flood() {
let (tx, mut rx) = unbounded_channel::<&'static str>();
for _ in 0..800 {
tx.send("sbx").unwrap();
}
tx.send("CHAT").unwrap();
for _ in 0..800 {
tx.send("sbx").unwrap();
}
let mut turns = 0usize;
let mut saw_chat = false;
while let Ok(first) = rx.try_recv() {
let mut burst = vec![first];
drain_ready(&mut rx, &mut burst, 256);
turns += 1;
if burst.contains(&"CHAT") {
saw_chat = true;
break;
}
}
assert!(saw_chat, "chat frame must be observed");
assert!(
turns <= 4,
"chat took {turns} turns to surface (expected <= 4)"
);
}
fn user(name: &str) -> User {
User {
user_id: format!("id-{name}"),
username: name.into(),
}
}
/// The host is the first occupant of the roster, and an empty roster has no
/// host — so a freshly-built app (no users yet) confers the title on nobody.
#[test]
fn host_is_first_roster_member() {
let mut app = App::new("alice".into());
assert_eq!(app.host(), None, "empty roster has no host");
app.users = vec![user("alice"), user("bob")];
assert_eq!(app.host(), Some("alice"));
// Host follows roster order, not who `me` is.
app.users = vec![user("bob"), user("alice")];
assert_eq!(app.host(), Some("bob"));
}
/// The host wears the Host badge with zero sandbox activity — Option A: the
/// title shows the moment they're in the room, not only after a launch.
#[test]
fn host_badge_shows_without_a_sandbox() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
assert_eq!(app.roles_of("alice"), vec![Role::Host]);
assert_eq!(app.roles_of("bob"), vec![Role::Member]);
}
/// Badges stack: a host who summoned a sandbox (sudoer) and can drive holds
/// all three at once, in paint order Host → Sudoer → Driver.
#[test]
fn roles_stack_additively() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
app.sudoers.insert("alice".into());
app.drivers.insert("alice".into());
assert_eq!(
app.roles_of("alice"),
vec![Role::Host, Role::Sudoer, Role::Driver]
);
}
/// A non-host can hold powers independently of the host title: bob granted
/// drive shows Driver only — never Host, and not a bare Member.
#[test]
fn non_host_powers_are_independent_of_the_title() {
let mut app = App::new("alice".into());
app.users = vec![user("alice"), user("bob")];
app.drivers.insert("bob".into());
assert_eq!(app.roles_of("bob"), vec![Role::Driver]);
assert_eq!(app.roles_of("alice"), vec![Role::Host]);
}
}