feat(sandbox): masked sudo modal for VirtualBox install path

The VBox GUI launch path previously had no in-TUI password capture — it
relied on cached creds (`sudo -n`) and otherwise aborted with guidance.
Generalize the existing masked sudo modal to cover it: `SudoPrompt.pending`
becomes a `PendingPrivileged` enum (container `Launch` | `VboxInstall`), so
the same local-only, never-logged password buffer now authorizes a VBox
install too. On submit the captured password is fed to `ensure_vbox_install`
via `sudo -S`, then the VM boots. Empty/Esc still cancel cleanly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-08 11:13:18 -07:00
parent c6eeb9b897
commit 18a8936caa
+78 -20
View File
@@ -188,13 +188,34 @@ pub struct PendingSudoLaunch {
pub gui: bool, pub gui: 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 — /// 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 /// 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 launch. /// 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. /// Deliberately no `Debug` derive so the secret can't be logged by accident.
pub struct SudoPrompt { pub struct SudoPrompt {
pub password: String, pub password: String,
pub pending: PendingSudoLaunch, pub pending: PendingPrivileged,
} }
/// Launch parameters captured when a GUI sandbox's noVNC port is already bound /// Launch parameters captured when a GUI sandbox's noVNC port is already bound
@@ -1055,7 +1076,10 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
app.sudo_prompt.take().unwrap(); app.sudo_prompt.take().unwrap();
if password.is_empty() { if password.is_empty() {
app.sys("⛧ cancelled — empty password"); app.sys("⛧ cancelled — empty password");
} else if let Some(h) = } else {
match pending {
PendingPrivileged::Launch(pending) => {
if let Some(h) =
if pending.gui { sbx::port_holder(sbx::GUI_PORT) } else { None } if pending.gui { sbx::port_holder(sbx::GUI_PORT) } else { None }
{ {
// Root's now authenticated, but the noVNC port // Root's now authenticated, but the noVNC port
@@ -1101,6 +1125,28 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
); );
} }
} }
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 => { KeyCode::Esc => {
app.sudo_prompt = None; app.sudo_prompt = None;
app.sys("⛧ sudo cancelled — launch aborted"); app.sys("⛧ sudo cancelled — launch aborted");
@@ -2143,7 +2189,7 @@ fn handle_command(
// `sudo -S`. The launch fires on submit (see the run loop). // `sudo -S`. The launch fires on submit (see the run loop).
app.sudo_prompt = Some(SudoPrompt { app.sudo_prompt = Some(SudoPrompt {
password: String::new(), password: String::new(),
pending: PendingSudoLaunch { pending: PendingPrivileged::Launch(PendingSudoLaunch {
backend, backend,
image, image,
members, members,
@@ -2152,7 +2198,7 @@ fn handle_command(
start_daemon, start_daemon,
install_first, install_first,
gui, gui,
}, }),
}); });
app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room."); app.sys("🔒 sudo password needed — type it here (hidden), Enter to launch · Esc cancels. Local only: never sent to the room.");
} else if let Some(h) = if gui { sbx::port_holder(sbx::GUI_PORT) } else { None } { } else if let Some(h) = if gui { sbx::port_holder(sbx::GUI_PORT) } else { None } {
@@ -2950,7 +2996,7 @@ fn launch_vbox_gui(
// Host path: VirtualBox + this VM already present and nothing to stop → boot // Host path: VirtualBox + this VM already present and nothing to stop → boot
// immediately, no confirmation. // immediately, no confirmation.
if installed && have_vm && conflicts.is_empty() { if installed && have_vm && conflicts.is_empty() {
spawn_vm_execute(vm, conflicts, false, false, app, app_tx, out_tx, room); spawn_vm_execute(vm, conflicts, false, false, app, app_tx, out_tx, room, None);
return; return;
} }
@@ -2990,17 +3036,25 @@ fn launch_vbox_gui(
)); ));
return; return;
} }
// Installing VirtualBox needs root. This GUI path runs off-thread with no // Installing VirtualBox needs root. If creds aren't cached, capture the
// in-TUI password modal, so it relies on `sudo -n` (cached creds). If they // password in the same masked, local-only modal the container path uses —
// aren't cached the apt step would fail fast mid-launch — preempt that with // sudo can't prompt on the tty from inside the raw-mode TUI. The install +
// a clear, actionable message rather than a cryptic install error. // VM boot fires on submit (see the run loop's sudo handler).
if needs_install && !sbx::sudo_ready() { if needs_install && !sbx::sudo_ready() {
app.err(format!( app.sudo_prompt = Some(SudoPrompt {
"installing VirtualBox needs sudo, but it isn't cached. Run `!sudo -v` here (caches your password ~15 min), then retry `/sbx vbox gui {vm} yes`." 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; return;
} }
spawn_vm_execute(vm, conflicts, needs_install, needs_import, app, app_tx, out_tx, room); 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), /// Final step of the gauntlet: off-thread, stop any VT-x holders (reversibly),
@@ -3020,6 +3074,10 @@ fn spawn_vm_execute(
app_tx: &UnboundedSender<Net>, app_tx: &UnboundedSender<Net>,
out_tx: &UnboundedSender<WsMsg>, out_tx: &UnboundedSender<WsMsg>,
room: &Arc<fernet::Fernet>, 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 // 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 // doesn't have to re-scan; None is fine unless an import actually proves
@@ -3041,18 +3099,18 @@ fn spawn_vm_execute(
let out = out_tx.clone(); let out = out_tx.clone();
let room = room.clone(); let room = room.clone();
let announce_vm = vm.clone(); let announce_vm = vm.clone();
let pw = password; // moved into the blocking install step below
tokio::spawn(async move { tokio::spawn(async move {
let res = tokio::task::spawn_blocking(move || { let res = tokio::task::spawn_blocking(move || {
for h in &conflicts { for h in &conflicts {
sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?; sbx::stop_vtx_holder(h).map_err(|e| format!("freeing VT-x: {e}"))?;
} }
if needs_install && !sbx::vbox_installed() { if needs_install && !sbx::vbox_installed() {
// VirtualBox install needs root. No in-TUI password was captured // VirtualBox install needs root. `pw` is `Some` when the masked
// on this path, so `ensure_vbox_install(None)` uses `sudo -n`: // modal captured a password (creds weren't cached) → fed to
// it succeeds if creds are cached (the confirm step asks the user // `sudo -S`; `None` means creds are cached → `sudo -n`. Either way
// to run `sudo -v` first) and fails fast with a clear message // it never hangs on a tty prompt the raw-mode TUI can't host.
// otherwise — never hanging on a tty prompt the TUI can't host. sbx::ensure_vbox_install(pw).map_err(|e| format!("install failed: {e}"))?;
sbx::ensure_vbox_install(None).map_err(|e| format!("install failed: {e}"))?;
} }
// Pull the shared appliance in if the VM still isn't registered. // Pull the shared appliance in if the VM still isn't registered.
if needs_import && !sbx::vm_registered(&vm) { if needs_import && !sbx::vm_registered(&vm) {