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:
+78
-20
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user