feat(sbx): VirtualBox detect-first install + local GUI VM launch
Integrate VirtualBox as a local facility rather than a shared-PTY backend: a Windows guest has no shell to relay, so the honest fit is launching the VM's GUI on the caller's own machine (the "share a VM, run it locally" path) — no display is relayed to the room, so zero-knowledge is untouched. - ensure-vbox.sh: detect-first installer mirroring ensure-docker.sh; --check, --plan (real apt --simulate download plan, no changes), --yes; apt/dnf/ pacman/brew/winget; Secure Boot MOK warning. HH_VBOX_FORCE_MISSING lets a demo exercise the missing->install path without uninstalling. - sbx.rs: vbox_installed/vbox_version/list_vms/vm_running/gui_launch + ensure_vbox_install. - app.rs: /sbx vms (detect + list) and /sbx gui <vm> [--install] (detect-first then startvm --type gui); /sbx launch virtualbox steers to /sbx gui. - ui.rs help: /sbx vms and /sbx gui entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
60168a4341
commit
7519df1695
131
hh/ensure-vbox.sh
Executable file
131
hh/ensure-vbox.sh
Executable file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env bash
|
||||
# ensure-vbox.sh — make sure VirtualBox is installed before /sbx launch virtualbox
|
||||
# or /sbx gui <vm>.
|
||||
#
|
||||
# Detect-first, never silent: if VirtualBox is already present this exits 0 and
|
||||
# changes nothing. If it's missing it prints the EXACT command it would run and
|
||||
# only installs with explicit consent (--yes). --plan shows a real, no-change
|
||||
# download plan (apt's own --simulate) so you can see what would be fetched.
|
||||
#
|
||||
# usage:
|
||||
# ./ensure-vbox.sh # interactive: prompt before installing
|
||||
# ./ensure-vbox.sh --yes # install without prompting (used by --install)
|
||||
# ./ensure-vbox.sh --check # test only; exit 0 if present, 1 if missing
|
||||
# ./ensure-vbox.sh --plan # show the install/download plan; change nothing
|
||||
set -uo pipefail
|
||||
|
||||
ASSUME_YES=0
|
||||
CHECK_ONLY=0
|
||||
PLAN_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-y|--yes) ASSUME_YES=1 ;;
|
||||
--check) CHECK_ONLY=1 ;;
|
||||
--plan|--dry-run) PLAN_ONLY=1 ;;
|
||||
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "✖ unknown arg: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# HH_VBOX_FORCE_MISSING=1 lets a demo exercise the missing→install path without
|
||||
# actually uninstalling anything (the probe is the single source of truth).
|
||||
installed() {
|
||||
[[ "${HH_VBOX_FORCE_MISSING:-0}" = "1" ]] && return 1
|
||||
command -v VBoxManage >/dev/null 2>&1 && VBoxManage --version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
vbox_version() { VBoxManage --version 2>/dev/null | head -1; }
|
||||
|
||||
# Already present: report and stop (idempotent). --plan still prints the plan.
|
||||
if installed; then
|
||||
if [[ $PLAN_ONLY -ne 1 ]]; then
|
||||
[[ $CHECK_ONLY -eq 1 ]] || echo "VirtualBox already installed ($(vbox_version)) — nothing to do" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
[[ $CHECK_ONLY -eq 1 ]] && exit 1
|
||||
|
||||
# Work out how to install on this platform, and the matching --simulate/plan cmd.
|
||||
# The plan command is deliberately runnable WITHOUT root where the tool allows it
|
||||
# (apt --simulate does), so the download plan can be shown with zero privilege.
|
||||
install_cmd=""
|
||||
plan_cmd=""
|
||||
need_sudo=0
|
||||
plan_sudo=0
|
||||
manual_note=""
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
install_cmd="apt-get install -y virtualbox"
|
||||
plan_cmd="apt-get install --simulate virtualbox" # no root needed
|
||||
need_sudo=1
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
install_cmd="dnf install -y VirtualBox"
|
||||
plan_cmd="dnf install --assumeno VirtualBox"
|
||||
need_sudo=1; plan_sudo=1
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
install_cmd="pacman -S --noconfirm virtualbox"
|
||||
plan_cmd="pacman -S --print virtualbox"
|
||||
need_sudo=1; plan_sudo=1
|
||||
fi
|
||||
;;
|
||||
Darwin)
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
install_cmd="brew install --cask virtualbox"
|
||||
plan_cmd="brew info --cask virtualbox"
|
||||
manual_note="macOS: you'll be asked to approve Oracle's kernel extension in System Settings → Privacy & Security, then reboot."
|
||||
fi
|
||||
;;
|
||||
MINGW*|MSYS*|CYGWIN*)
|
||||
if command -v winget >/dev/null 2>&1; then
|
||||
install_cmd="winget install -e --id Oracle.VirtualBox"
|
||||
plan_cmd="winget show -e --id Oracle.VirtualBox"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "$install_cmd" ]]; then
|
||||
echo "✖ don't know how to install VirtualBox here — get it from https://www.virtualbox.org/wiki/Downloads" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ $need_sudo -eq 1 ]] && install_cmd="sudo $install_cmd"
|
||||
[[ $plan_sudo -eq 1 ]] && plan_cmd="sudo $plan_cmd"
|
||||
|
||||
# Secure Boot needs the vboxdrv kernel module signed/enrolled (MOK) or it won't
|
||||
# load. We check and warn rather than fail opaquely — it's a one-time manual step.
|
||||
if command -v mokutil >/dev/null 2>&1; then
|
||||
if mokutil --sb-state 2>/dev/null | grep -qi 'SecureBoot enabled'; then
|
||||
echo "⚠ Secure Boot is ENABLED — after install you must enroll a MOK so the" >&2
|
||||
echo " vboxdrv kernel module can load (the installer prompts for a password" >&2
|
||||
echo " you re-enter at the blue MOK screen on next reboot)." >&2
|
||||
fi
|
||||
fi
|
||||
[[ -n "$manual_note" ]] && echo "ⓘ $manual_note" >&2
|
||||
|
||||
# --plan: show the real download/install plan and change nothing.
|
||||
if [[ $PLAN_ONLY -eq 1 ]]; then
|
||||
echo "plan (no changes will be made): $plan_cmd" >&2
|
||||
eval "$plan_cmd"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Confirmation (skipped with --yes).
|
||||
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||
printf 'VirtualBox is not installed. Install it with "%s"? [y/N] ' "$install_cmd" >&2
|
||||
read -r reply
|
||||
case "$reply" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "✖ aborted — VirtualBox left uninstalled" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "installing VirtualBox: $install_cmd" >&2
|
||||
eval "$install_cmd" || { echo "✖ install failed (sudo password needed? run it in a terminal)" >&2; exit 1; }
|
||||
|
||||
# Confirm the binary is now callable.
|
||||
if installed; then
|
||||
echo "VirtualBox is ready ($(vbox_version))" >&2
|
||||
exit 0
|
||||
fi
|
||||
echo "✖ install ran but VBoxManage is still not callable — check the install log" >&2
|
||||
exit 1
|
||||
|
|
@ -1191,6 +1191,9 @@ fn handle_command(
|
|||
let start_daemon = args
|
||||
.iter()
|
||||
.any(|a| matches!(*a, "--start" | "--start-daemon" | "-y"));
|
||||
// VirtualBox isn't a shared-PTY backend — a GUI VM (a Windows
|
||||
// guest especially) has no shell to relay. Steer to /sbx gui.
|
||||
let wants_vbox = args.iter().any(|a| matches!(*a, "virtualbox" | "vbox"));
|
||||
let mut pos = args.iter().copied().filter(|a| !a.starts_with('-'));
|
||||
let backend = pos
|
||||
.next()
|
||||
|
|
@ -1201,7 +1204,11 @@ fn handle_command(
|
|||
.map(str::to_string)
|
||||
.unwrap_or_else(|| backend.default_image().to_string());
|
||||
|
||||
if backend == sbx::Backend::Docker && !start_daemon && !sbx::docker_daemon_up()
|
||||
if wants_vbox {
|
||||
app.sys("VirtualBox VMs run locally in their own GUI — use `/sbx gui <vm>` (list them with `/sbx vms`)");
|
||||
} else if backend == sbx::Backend::Docker
|
||||
&& !start_daemon
|
||||
&& !sbx::docker_daemon_up()
|
||||
{
|
||||
app.err("docker daemon is not running — retry with `/sbx launch docker --start` to boot it (sudo), or run ./ensure-docker.sh in a terminal first");
|
||||
} else {
|
||||
|
|
@ -1305,8 +1312,67 @@ fn handle_command(
|
|||
};
|
||||
});
|
||||
}
|
||||
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 ./ensure-vbox.sh".to_string()
|
||||
})?;
|
||||
let vms = sbx::list_vms().map_err(|e| e.to_string())?;
|
||||
Ok::<_, String>((ver, vms))
|
||||
})
|
||||
.await;
|
||||
let _ = match res {
|
||||
Ok(Ok((ver, v))) if !v.is_empty() => tx.send(Net::Sys(format!(
|
||||
"VirtualBox {ver} detected · VMs: {}",
|
||||
v.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("gui") => {
|
||||
let gargs: Vec<&str> = p.collect();
|
||||
let install = gargs.iter().any(|a| *a == "--install");
|
||||
let name = gargs
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|a| !a.starts_with('-'))
|
||||
.map(str::to_string);
|
||||
match name {
|
||||
None => app.sys("usage: /sbx gui <vm> [--install] (list VMs with /sbx vms)"),
|
||||
Some(vm) => {
|
||||
app.sys(format!("launching {vm} in the VirtualBox GUI…"));
|
||||
let tx = app_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
if !sbx::vbox_installed() {
|
||||
if install {
|
||||
sbx::ensure_vbox_install()
|
||||
.map_err(|e| format!("install failed: {e}"))?;
|
||||
} else {
|
||||
return Err("VirtualBox isn't installed — retry with `/sbx gui <vm> --install` (needs sudo), or run ./ensure-vbox.sh first".to_string());
|
||||
}
|
||||
}
|
||||
sbx::gui_launch(&vm).map_err(|e| e.to_string())
|
||||
})
|
||||
.await;
|
||||
let _ = match res {
|
||||
Ok(Ok(desc)) => tx.send(Net::Sys(format!("⛧ {desc}"))),
|
||||
Ok(Err(e)) => tx.send(Net::Err(e)),
|
||||
Err(e) => tx.send(Net::Err(format!("gui task: {e}"))),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => app.sys(
|
||||
"usage: /sbx launch [local|docker|multipass] [image] · stop · save [label] · load <label> · snaps",
|
||||
"usage: /sbx launch [local|docker|multipass] [image] · gui <vm> · vms · stop · save [label] · load <label> · snaps",
|
||||
),
|
||||
}
|
||||
} else if let Some(rest) = line.strip_prefix("/unsudo") {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ use std::sync::mpsc;
|
|||
|
||||
/// Helper that ensures the Docker daemon is running (ships beside this source).
|
||||
const ENSURE_DOCKER: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ensure-docker.sh");
|
||||
/// Detect-first VirtualBox installer (ships beside this source).
|
||||
const ENSURE_VBOX: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ensure-vbox.sh");
|
||||
|
||||
/// Is the Docker daemon accepting connections? (`docker info` succeeds.)
|
||||
pub fn docker_daemon_up() -> bool {
|
||||
|
|
@ -45,6 +47,102 @@ fn start_docker_daemon() -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ---- VirtualBox (local GUI VMs) ---------------------------------------------
|
||||
// VirtualBox is integrated as a *local* facility rather than a shared-PTY
|
||||
// backend: a room shares a VM by handing out its appliance, and each member
|
||||
// boots it in the real VirtualBox GUI on their own machine. None of this relays
|
||||
// over the room — only the (separately `/send`-ed) image does — so the
|
||||
// zero-knowledge model is untouched. A Windows guest has no sshd/guestcontrol
|
||||
// shell to drive, so the GUI launch is the honest fit, not a faked PTY.
|
||||
|
||||
/// Is VirtualBox installed? (`VBoxManage --version` succeeds.)
|
||||
pub fn vbox_installed() -> bool {
|
||||
Command::new("VBoxManage")
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// VirtualBox version string (e.g. `7.1.2r164945`), or None if not installed.
|
||||
pub fn vbox_version() -> Option<String> {
|
||||
let out = Command::new("VBoxManage").arg("--version").output().ok()?;
|
||||
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(out.status.success() && !v.is_empty()).then_some(v)
|
||||
}
|
||||
|
||||
/// Install VirtualBox via `ensure-vbox.sh --yes`. Consent is the caller's job
|
||||
/// (they passed `--install`); detection is the script's (idempotent if present).
|
||||
/// Returns the script's last error line on failure (e.g. needs sudo).
|
||||
pub fn ensure_vbox_install() -> Result<()> {
|
||||
let out = Command::new("bash")
|
||||
.arg(ENSURE_VBOX)
|
||||
.arg("--yes")
|
||||
.output()
|
||||
.context("running ensure-vbox.sh")?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let last = err.lines().last().unwrap_or("could not install VirtualBox");
|
||||
anyhow::bail!("{last}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Names of registered VirtualBox VMs (`VBoxManage list vms`). Each line is
|
||||
/// `"name" {uuid}`; we return the unquoted names.
|
||||
pub fn list_vms() -> Result<Vec<String>> {
|
||||
let out = Command::new("VBoxManage")
|
||||
.args(["list", "vms"])
|
||||
.output()
|
||||
.context("VBoxManage list vms (is VirtualBox installed?)")?;
|
||||
Ok(String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let start = l.find('"')? + 1;
|
||||
let end = l[start..].find('"')? + start;
|
||||
Some(l[start..end].to_string())
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Is a VM currently running? (`VBoxManage list runningvms`)
|
||||
pub fn vm_running(name: &str) -> bool {
|
||||
Command::new("VBoxManage")
|
||||
.args(["list", "runningvms"])
|
||||
.output()
|
||||
.map(|o| {
|
||||
let needle = format!("\"{name}\"");
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.lines()
|
||||
.any(|l| l.contains(&needle))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Launch a registered VM's GUI locally (`VBoxManage startvm <name> --type gui`).
|
||||
/// The window opens on the caller's own desktop — this is the "share a VM, run
|
||||
/// it locally" path; nothing about the display is relayed to the room.
|
||||
pub fn gui_launch(name: &str) -> Result<String> {
|
||||
if vm_running(name) {
|
||||
return Ok(format!("{name} is already running"));
|
||||
}
|
||||
let out = Command::new("VBoxManage")
|
||||
.args(["startvm", name, "--type", "gui"])
|
||||
.output()
|
||||
.context("VBoxManage startvm (is VirtualBox installed?)")?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
anyhow::bail!(
|
||||
"startvm failed: {}",
|
||||
err.lines().last().unwrap_or("").trim()
|
||||
);
|
||||
}
|
||||
Ok(format!("launched {name} (GUI)"))
|
||||
}
|
||||
|
||||
/// Which sandbox to summon. Multipass = strong isolation (default for real use),
|
||||
/// Docker = fast, Local = no isolation (dev/testing only).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -154,6 +154,8 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||
kv("/sbx save [label]", "snapshot state (docker image; survives stop)"),
|
||||
kv("/sbx load <label>", "launch a fresh sandbox from a saved snapshot"),
|
||||
kv("/sbx snaps", "list saved snapshots"),
|
||||
kv("/sbx vms", "list local VirtualBox VMs"),
|
||||
kv("/sbx gui <vm>", "boot a VirtualBox VM locally in its GUI"),
|
||||
kv("/drive · F2", "type into the shared shell (Esc releases)"),
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user