feat(hh): P3 — summonable sandbox + shared PTY ⛧
Collaborative sandbox over the same zero-knowledge encrypted channel:
- sbx.rs: SandboxBackend (Local / Docker / Multipass) spawning a shell in a PTY
(portable-pty); reader thread pumps output to the broker.
- Broker (owner's client): /sbx launch [backend] [image] boots the sandbox and
relays PTY output as encrypted {"_sbx":"data"} frames; /sbx stop tears down.
PTY input arrives as {"_sbx":"input"} frames and is written back.
- All clients render the shared terminal from data frames via a vt100 parser;
F2 toggles drive mode (keystrokes -> input frames, incl. Ctrl-C); esc releases.
- ui.rs: sandbox pane (split below chat) with drive indicator.
- Server stays zero-knowledge: PTY bytes are Fernet-encrypted like chat/files;
the VM runs on the initiator's client, never the server.
Tests (cargo test, 4 pass): PTY I/O round-trip + headless end-to-end relay
(PTY -> _sbx frame encode -> decode -> vt100 screen shows command output).
Note: Multipass assumes the instance is launched separately (lifecycle = P3b);
per-user unix accounts + sudo delegation = P4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+36
-1
@@ -18,13 +18,48 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
||||
|
||||
draw_top(f, rows[0], app, theme);
|
||||
|
||||
// When a sandbox is live, split the body: chat+roster on top, PTY below.
|
||||
let (chat_area, sbx_area) = if app.sandbox.is_some() {
|
||||
let split = Layout::vertical([Constraint::Percentage(45), Constraint::Percentage(55)])
|
||||
.split(rows[1]);
|
||||
(split[0], Some(split[1]))
|
||||
} else {
|
||||
(rows[1], None)
|
||||
};
|
||||
|
||||
let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)])
|
||||
.split(rows[1]);
|
||||
.split(chat_area);
|
||||
draw_chat(f, body[0], app, theme);
|
||||
draw_roster(f, body[1], app, theme);
|
||||
if let Some(area) = sbx_area {
|
||||
draw_sandbox(f, area, app, theme);
|
||||
}
|
||||
draw_input(f, rows[2], app, theme);
|
||||
}
|
||||
|
||||
fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let Some(sv) = &app.sandbox else { return };
|
||||
let screen = sv.parser.screen();
|
||||
let (_rows, cols) = screen.size();
|
||||
let lines: Vec<Line> = screen
|
||||
.rows(0, cols)
|
||||
.map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title))))
|
||||
.collect();
|
||||
let drive = if app.driving {
|
||||
" · DRIVING (esc to release)"
|
||||
} else {
|
||||
" · F2 to drive"
|
||||
};
|
||||
let title = format!(" sandbox · {}{} ", sv.backend, drive);
|
||||
let border = if app.driving { theme.accent } else { theme.border };
|
||||
let pane = Paragraph::new(lines).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(border))
|
||||
.title(Span::styled(title, Style::default().fg(theme.title))),
|
||||
);
|
||||
f.render_widget(pane, area);
|
||||
}
|
||||
|
||||
fn draw_top(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
|
||||
let cap = if app.capacity > 0 { app.capacity } else { app.users.len() };
|
||||
let status = if app.connected { "🔒 e2e" } else { "✖ closed" };
|
||||
|
||||
Reference in New Issue
Block a user