feat(sandbox): VM library — pointer catalog + local build via /sbx vmlib
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run

Add an installable-VM catalog that ships pointers only (no multi-GB images
in the repo). When a VM is chosen it is BUILT LOCALLY on the caller's own
machine — nothing is relayed to the room.

- scripts/vbox-library.json: 7-entry manifest (Windows 11, macOS Sonoma,
  Kali, Parrot, Ubuntu 24.04, Fedora 41, Debian 12) with download
  pointers, ostype, cpu/mem/disk, and build kind.
- scripts/vbox-library.sh: --list / --info / --plan / --install. Build
  kinds: iso (download/--iso + createvm + boot installer; EFI+TPM for
  Win11), cloudimg (delegate to vbox-new.sh, unattended), ova (import),
  manual (pointer-only, e.g. macOS per Apple licensing). Detect-first:
  --plan changes nothing, install refuses to clobber and rolls back
  half-built VMs, direct URLs fall back to the page + --iso <path>.
- sbx.rs: vbox_library()/library_vm()/vbox_library_install() loaders +
  running_vms() for live-state markers.
- app.rs: /sbx vmlib (catalog ✓installed/↓available), /sbx vmlib <id>
  (pointer/notes), /sbx vmlib <id> install [--iso path] (local build);
  /sbx vms now flags running VMs (▶). Registered in SBX_SUBCOMMANDS +
  usage.
- ui.rs: help entry for the VM library.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-08 12:09:54 -07:00
parent 15aa2027c4
commit f93c8c5e4f
5 changed files with 612 additions and 6 deletions
+110 -6
View File
@@ -2291,15 +2291,23 @@ fn handle_command(
"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())?;
Ok::<_, String>((ver, vms))
let running = sbx::running_vms();
Ok::<_, String>((ver, vms, running))
})
.await;
let _ = match res {
Ok(Ok((ver, v))) if !v.is_empty() => tx.send(Net::Sys(format!(
Ok(Ok((ver, v, running))) if !v.is_empty() => tx.send(Net::Sys(format!(
"VirtualBox {ver} detected · VMs: {}",
v.join(", ")
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!(
Ok(Ok((ver, _, _))) => tx.send(Net::Sys(format!(
"VirtualBox {ver} detected · no VMs registered"
))),
Ok(Err(e)) => tx.send(Net::Err(e)),
@@ -2401,6 +2409,102 @@ fn handle_command(
}
}
}
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`
@@ -2416,7 +2520,7 @@ fn handle_command(
}
}
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) · vms · stop · save [label] [--local] · load <label> · snaps · vmsave <vm> [label] [--local] · vmload <vm> [label] · vmsnaps <vm>";
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))) {
@@ -2686,7 +2790,7 @@ const KNOWN_COMMANDS: &[&str] = &[
/// 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",
"vmsave", "vmload", "vmsnaps", "vmlib",
];
/// Classic Levenshtein edit distance (insert/delete/substitute, each cost 1).
+120
View File
@@ -26,6 +26,11 @@ const SBX_BOOTSTRAP: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbo
const SBX_TOOLS_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/sandbox-tools.json");
/// Creates a fresh, cloud-init-provisioned Ubuntu VirtualBox VM (hh/scripts/).
const VBOX_NEW: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-new.sh");
/// Browse + locally-build VMs from the curated library (hh/scripts/).
const VBOX_LIBRARY_SH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-library.sh");
/// The library manifest: pointers (URLs/pages) to VMs, never the images. The
/// loader cross-references `list_vms` to mark which are already built locally.
const VBOX_LIBRARY_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/scripts/vbox-library.json");
/// Is the `docker` binary installed? (`docker --version` succeeds.) This is a
/// weaker check than `docker_daemon_up`: the CLI can be present while the daemon
@@ -379,6 +384,121 @@ pub fn vbox_new(name: &str, user: &str) -> Result<String> {
})
}
/// Names of VMs that are currently running (`VBoxManage list runningvms`), so the
/// `/sbx vms` listing can flag which of the registered VMs are live.
pub fn running_vms() -> Vec<String> {
Command::new("VBoxManage")
.args(["list", "runningvms"])
.output()
.map(|o| {
String::from_utf8_lossy(&o.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()
})
.unwrap_or_default()
}
// ---- VM library (curated pointers; built locally on demand) -----------------
//
// The repo ships scripts/vbox-library.json — POINTERS only (download URLs/pages),
// never the multi-GB images. Choosing a VM builds it on the caller's own machine
// via scripts/vbox-library.sh. Here we parse the manifest for listing/info; the
// heavy build is shelled out to the script (which already handles download +
// VBoxManage create/import + boot, with rollback).
/// One curated VM as declared in the manifest. Only the fields we surface in the
/// TUI are deserialized; the rest of the JSON (specs, firmware, etc.) is consumed
/// by the build script.
#[derive(Clone, Debug, serde::Deserialize)]
pub struct LibraryVm {
pub id: String,
pub name: String,
pub os: String,
pub size: String,
#[serde(default)]
pub page: String,
#[serde(default)]
pub notes: String,
/// Filled in by `vbox_library()` (not in the JSON): is a VM of this name
/// already registered in VirtualBox on this machine?
#[serde(skip)]
pub installed: bool,
}
#[derive(serde::Deserialize)]
struct LibraryManifest {
vms: Vec<LibraryVm>,
}
/// Parse the VM library manifest, marking each entry `installed` if a VM of that
/// display name is already registered locally (`list_vms`). The catalog is the
/// same for everyone; `installed` is per-machine, so this works identically for a
/// host or any room member — VirtualBox VMs are always local to the caller.
pub fn vbox_library() -> Result<Vec<LibraryVm>> {
let text = std::fs::read_to_string(VBOX_LIBRARY_JSON)
.with_context(|| format!("reading VM library manifest ({VBOX_LIBRARY_JSON})"))?;
let manifest: LibraryManifest =
serde_json::from_str(&text).context("parsing vbox-library.json")?;
let have = list_vms().unwrap_or_default();
Ok(manifest
.vms
.into_iter()
.map(|mut v| {
v.installed = have.iter().any(|n| n == &v.name);
v
})
.collect())
}
/// Look up a single library entry by its id (with `installed` resolved).
pub fn library_vm(id: &str) -> Option<LibraryVm> {
vbox_library().ok()?.into_iter().find(|v| v.id == id)
}
/// Build a library VM locally by driving scripts/vbox-library.sh `--install`.
/// Blocking and potentially slow (large download); run off the UI thread. No
/// sudo is needed — VBoxManage create/import run as the user. `iso` supplies a
/// locally-downloaded installer for entries with no auto-download (e.g. Windows).
/// Returns the script's final status line(s).
pub fn vbox_library_install(id: &str, iso: Option<String>) -> Result<String> {
let mut cmd = Command::new("bash");
cmd.arg(VBOX_LIBRARY_SH).args(["--install", id, "--yes"]);
if let Some(path) = iso.as_deref() {
cmd.args(["--iso", path]);
}
let out = cmd
.output()
.context("running vbox-library.sh (is bash available?)")?;
let stderr = String::from_utf8_lossy(&out.stderr);
if !out.status.success() {
// The script narrates on stderr and ends with a ✖ reason / pointer.
let reason = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("build failed")
.trim();
anyhow::bail!("{reason}");
}
// Surface the final ✓/ⓘ status line.
let last = stderr
.lines()
.rev()
.find(|l| {
let t = l.trim_start();
t.starts_with('✓') || t.starts_with('ⓘ')
})
.unwrap_or("done")
.trim();
Ok(last.to_string())
}
/// A running hypervisor that holds the CPU's VT-x/VMX root mode. While one is
/// live, VirtualBox can't boot a *hardware* VM (it aborts with
/// `VERR_VMX_IN_VMX_ROOT_MODE`). We only mark holders we know how to stop
+4
View File
@@ -293,6 +293,10 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
"/sbx vbox new [name]",
"build a FRESH Ubuntu VM from a cloud image (cloud-init installs the dev toolchain)",
),
kv(
"/sbx vmlib [<id> [install]]",
"VM library — catalog of installable VMs (Win11, macOS, Kali…); <id> install builds it LOCALLY on your machine (pointers only, no images bundled)",
),
kv(
"/sbx vbox [gui] <vm> [yes]",
"boot a VirtualBox VM's GUI on YOUR machine (non-host appends yes to install/import first)",